Creational

Singleton (Одинак)

Singleton (Одинак)

Вступ та Контекст

Уявіть, що ви розробляєте систему логування для масштабного додатку. Кожен компонент системи має записувати логи у файл, але якщо кожен компонент створюватиме власний екземпляр Logger, це призведе до:

  • Конфлікту доступу до файлу (кілька процесів намагаються писати одночасно)
  • Надмірного споживання пам'яті (сотні однакових об'єктів Logger)
  • Втрати цілісності даних (перезапис, пошкодження файлів)

Singleton (Одинак) — це породжуючий патерн проектування, який гарантує існування лише одного екземпляру класу і надає глобальну точку доступу до нього.

Singleton вирішує дві фундаментальні проблеми одночасно:
  1. Контроль створення: Забезпечує, щоб клас мав лише один екземпляр
  2. Глобальний доступ: Надає простий спосіб отримати цей екземпляр з будь-якої точки програми ::

Коли використовувати

СценарійОбґрунтування
Logger / Система логуванняОдин централізований сервіс для запису логів
Configuration ManagerОдин об'єкт конфігурації для всієї програми
Database Connection PoolУправління обмеженою кількістю з'єднань
Cache ManagerЄдине сховище кешованих даних
Device DriversОдин драйвер для доступу до апаратного пристрою
Обережно з Singleton!
Багато розробників вважають Singleton антипатерном, оскільки він:
  • Порушує принцип єдиної відповідальності (SRP) — клас контролює своє створення
  • Ускладнює юніт-тестування (складно підміняти mock-об'єкти)
  • Створює приховані залежності (код неявно залежить від глобального стану)
  • Може порушувати SOLID принципи
Використовуйте Singleton виключно тоді, коли справді потрібен єдиний екземпляр і є обґрунтована причина для глобального доступу.

Фундаментальні Концепції

Академічне визначення

Singleton (Одинак) — це породжуючий патерн проектування, який гарантує, що клас має лише один екземпляр, і надає глобальну точку доступу до цього екземпляру. Патерн делегує відповідальність за контроль створення самому класу, а не зовнішньому коду.

Ключова ідея

Проблема створення єдиного екземпляру виникає з того, що звичайний конструктор завжди повертає новий об'єкт. Singleton вирішує це через:
  1. Приватний конструктор — заборона зовнішнього створення об'єктів
  2. Статичне поле — зберігання єдиного екземпляру
  3. Статичний метод — контрольована точка доступу
Історична довідка
Singleton — один з 23 патернів, описаних у легендарній книзі "Design Patterns: Elements of Reusable Object-Oriented Software" (Gang of Four, 1994). Хоча патерн критикують за порушення SOLID принципів, він залишається корисним для специфічних випадків, де дійсно потрібен єдиний глобальний екземпляр.

Аналогія з реального світу

Уряд країни — класичний приклад Singleton:
  • У країні може бути тільки один офіційний уряд (навіть якщо змінюється склад)
  • Будь-який громадянин може звернутися до уряду через публічні канали
  • Неможливо "створити" другий офіційний уряд — є лише один легітимний
Так само Singleton в програмуванні забезпечує одну авторитетну інстанцію для певного функціоналу.

Архітектура та Механіка

UML діаграма класів

Loading diagram...
classDiagram
    class Singleton {
        -Singleton instance$
        -Singleton()
        +GetInstance()$ Singleton
        +BusinessLogic()
    }

    class Client {
        +Main()
    }

    Client ..> Singleton : використовує
    Singleton --> Singleton : зберігає єдиний екземпляр

    style Singleton fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Client fill:#64748b,stroke:#334155,color:#ffffff
Пояснення:
  • - instance: Singleton — статичне приватне поле, яке зберігає єдиний екземпляр
  • - Singleton() — приватний конструктор (позначено -), заборонений для зовнішніх викликів
  • + GetInstance(): Singleton — публічний статичний метод (позначено + та $), єдина точка доступу
  • Клієнт не може створити новий об'єкт через new, лише через GetInstance()

Діаграма послідовності

Loading diagram...
sequenceDiagram
    participant Client1 as Клієнт 1
    participant Client2 as Клієнт 2
    participant Singleton as Singleton

    Client1->>+Singleton: GetInstance()
    Note over Singleton: Перевірка: instance == null?
    Singleton->>Singleton: Створення екземпляру
    Singleton-->>-Client1: Повернути instance

    Client2->>+Singleton: GetInstance()
    Note over Singleton: instance != null
    Singleton-->>-Client2: Повернути той самий instance

    Note over Client1,Client2: Обидва клієнти працюють з одним об'єктом

    style Singleton fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style Client1 fill:#64748b,stroke:#334155,color:#ffffff
    style Client2 fill:#64748b,stroke:#334155,color:#ffffff

Учасники та їх ролі

УчасникРольВідповідальність
SingletonКлас-одинакЗабезпечує єдиний екземпляр, контролює доступ через статичний метод
ClientКлієнтОтримує екземпляр через GetInstance(), не може створити новий через new

Практична Реалізація

Варіації реалізації Singleton в C#

Існує кілька підходів до реалізації Singleton, кожен з яких має свої переваги та недоліки залежно від вимог до продуктивності та багатопоточності.

1. Наївна реалізація (Non-Thread-Safe)

Небезпечно в багатопоточному середовищі!
Цей підхід НЕ є потокобезпечним і може створити кілька екземплярів при одночасних викликах з різних потоків.
Logger.cs
namespace DesignPatterns.Creational.Singleton;

/// <summary>
/// Наївна реалізація Singleton (НЕ потокобезпечна)
/// </summary>
public class Logger
{
    // Статичне поле для зберігання єдиного екземпляру
    private static Logger? _instance;

    // Приватний конструктор заборонює створення через new
    private Logger()
    {
        Console.WriteLine("Logger ініціалізовано");
    }

    // Публічна точка доступу до екземпляру
    public static Logger GetInstance()
    {
        // ПРОБЛЕМА: Якщо два потоки одночасно увійдуть сюди,
        // обидва можуть створити новий екземпляр
        if (_instance == null)
        {
            _instance = new Logger();
        }

        return _instance;
    }

    public void Log(string message)
    {
        Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {message}");
    }
}
Використання:
Program.cs
// Отримуємо екземпляр Logger (створюється при першому виклику)
var logger1 = Logger.GetInstance();
logger1.Log("Перше повідомлення");

// Отримуємо той самий екземпляр
var logger2 = Logger.GetInstance();
logger2.Log("Друге повідомлення");

// Перевірка, що це той самий об'єкт
Console.WriteLine($"logger1 == logger2: {ReferenceEquals(logger1, logger2)}"); // True
Проблеми:
  • Не потокобезпечний — кілька потоків можуть створити різні екземпляри
  • Race condition — при одночасному доступі з різних потоків

2. Thread-Safe: Double-Checked Locking

Цей підхід використовує блокування (lock) для забезпечення потокобезпечності, але робить це оптимізовано через подвійну перевірку.
ConfigurationManager.cs
namespace DesignPatterns.Creational.Singleton;

/// <summary>
/// Thread-safe реалізація Singleton з Double-Checked Locking
/// </summary>
public sealed class ConfigurationManager
{
    private static ConfigurationManager? _instance;

    // Об'єкт для синхронізації потоків
    private static readonly object _lock = new();

    public Dictionary<string, string> Settings { get; }

    private ConfigurationManager()
    {
        Console.WriteLine("Configuration Manager ініціалізовано");
        Settings = new Dictionary<string, string>
        {
            ["AppName"] = "My Application",
            ["Version"] = "1.0.0"
        };
    }

    public static ConfigurationManager GetInstance()
    {
        // Перша перевірка (без блокування) — для продуктивності
        if (_instance == null)
        {
            // Блокування — лише якщо екземпляр ще не створений
            lock (_lock)
            {
                // Друга перевірка (всередині блокування) — для безпеки
                // Потрібна, бо інший потік міг створити екземпляр,
                // поки ми чекали на lock
                if (_instance == null)
                {
                    _instance = new ConfigurationManager();
                }
            }
        }

        return _instance;
    }

    public string GetSetting(string key)
    {
        return Settings.TryGetValue(key, out var value) ? value : "Not found";
    }
}
Пояснення коду:
  1. Рядок 19-20: Перша перевірка if (_instance == null) виконується без блокування для підвищення продуктивності. Якщо екземпляр вже створений, блокування не відбувається.
  2. Рядок 22: lock (_lock) — критична секція, де тільки один потік може перебувати одночасно.
  3. Рядок 26-27: Друга перевірка всередині lock необхідна, тому що доки перший потік чекав на lock, інший потік міг вже створити екземпляр.
  4. sealed: Клас позначений як sealed, щоб заборонити успадкування (це важливо для Singleton, бо наслідування може порушити гарантію єдиності екземпляру).
Double-Checked Locking Pattern
Цей патерн оптимізує продуктивність: блокування виконується лише один раз при першому створенні екземпляру. Всі наступні виклики уникають lock завдяки швидкій перевірці if (_instance == null).
Використання:
Program.cs
// Багатопоточне середовище
var tasks = new List<Task>();

for (int i = 0; i < 5; i++)
{
    int threadId = i;
    tasks.Add(Task.Run(() =>
    {
        var config = ConfigurationManager.GetInstance();
        Console.WriteLine($"Thread {threadId}: {config.GetSetting("AppName")}");
    }));
}

await Task.WhenAll(tasks);
// Виведе "Configuration Manager ініціалізовано" лише ОДИН раз

3. Thread-Safe: Lazy (Рекомендований підхід)

Найсучасніший і найбезпечніший підхід — використання вбудованого класу Lazy<T>, який автоматично забезпечує потокобезпечність та ледаче ініціалізацію.
DatabaseConnection.cs
namespace DesignPatterns.Creational.Singleton;

/// <summary>
/// Thread-safe Singleton через Lazy<T> — РЕКОМЕНДОВАНИЙ підхід
/// </summary>
public sealed class DatabaseConnection
{
    // Lazy<T> гарантує потокобезпечність та ледаче ініціалізацію
    private static readonly Lazy<DatabaseConnection> _lazyInstance =
        new(() => new DatabaseConnection());

    public static DatabaseConnection Instance => _lazyInstance.Value;

    public string ConnectionString { get; }

    private DatabaseConnection()
    {
        Console.WriteLine("Database Connection ініціалізовано");
        ConnectionString = "Server=localhost;Database=MyApp;";
    }

    public void ExecuteQuery(string query)
    {
        Console.WriteLine($"Виконується запит: {query}");
    }
}
Переваги Lazy:
ХарактеристикаОпис
ПотокобезпечністьLazy<T> автоматично використовує блокування
Ледаче ініціалізаціюОб'єкт створюється лише при першому звертанні до .Value
Мінімалізм кодуНе потрібно писати власну логіку блокування
ПродуктивністьОптимізований механізм блокування всередині .NET
Використання:
Program.cs
// Простий доступ через властивість Instance
var db1 = DatabaseConnection.Instance;
db1.ExecuteQuery("SELECT * FROM Users");

var db2 = DatabaseConnection.Instance;
Console.WriteLine($"db1 == db2: {ReferenceEquals(db1, db2)}"); // True
Best Practice
Використовуйте Lazy<T> для реалізації Singleton в C#. Це найпростіший, найбезпечніший та найефективніший підхід. Забудьте про ручне блокування!

4. Eager Initialization (Жадібна ініціалізація)

Екземпляр створюється одразу при завантаженні класу, ще до першого звертання. .NET гарантує потокобезпечність статичних конструкторів.
CacheManager.cs
namespace DesignPatterns.Creational.Singleton;

/// <summary>
/// Singleton з жадібною ініціалізацією (Eager Initialization)
/// </summary>
public sealed class CacheManager
{
    // Екземпляр створюється ОДРАЗУ при завантаженні класу
    private static readonly CacheManager _instance = new();

    public static CacheManager Instance => _instance;

    private readonly Dictionary<string, object> _cache;

    private CacheManager()
    {
        Console.WriteLine("Cache Manager ініціалізовано");
        _cache = new Dictionary<string, object>();
    }

    public void Set(string key, object value)
    {
        _cache[key] = value;
    }

    public object? Get(string key)
    {
        return _cache.TryGetValue(key, out var value) ? value : null;
    }
}
Коли використовувати:
✅ Підходить❌ Не підходить
Екземпляр завжди потрібен в програміСтворення об'єкту дороге за ресурсами
Ініціалізація швидкаЕкземпляр може не знадобитися взагалі
Просто та зрозумілоПотрібна ледача ініціалізація

Порівняльна таблиця підходів

ПідхідПотокобезпечністьЛедача ініціалізаціяСкладністьРекомендація
Наївний❌ Ні✅ ТакПроста❌ Уникати
Double-Checked Lock✅ Так✅ ТакСередня⚠️ Якщо немає Lazy
Lazy✅ Так✅ ТакПростаРекомендовано
Eager Initialization✅ Так❌ НіПроста⚠️ Якщо завжди потрібен

Реальні приклади з .NET

Приклад 1: HttpClient Singleton (Best Practice)

HttpClient — класичний приклад, де Singleton критично важливий. Створення багатьох екземплярів HttpClient призводить до вичерпання портів (socket exhaustion).
HttpClientSingleton.cs
namespace DesignPatterns.Creational.Singleton;

/// <summary>
/// Singleton для HttpClient — запобігає socket exhaustion
/// </summary>
public sealed class HttpClientSingleton
{
    private static readonly Lazy<HttpClient> _lazyClient = new(() =>
    {
        var client = new HttpClient
        {
            Timeout = TimeSpan.FromSeconds(30),
            BaseAddress = new Uri("https://api.example.com/")
        };

        client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");

        return client;
    });

    public static HttpClient Client => _lazyClient.Value;

    private HttpClientSingleton() { }
}
Використання:
Program.cs
// Всі запити використовують один HttpClient
var response1 = await HttpClientSingleton.Client.GetAsync("users");
var response2 = await HttpClientSingleton.Client.GetAsync("posts");

// Не створюються нові TCP з'єднання для кожного запиту
Сучасна альтернатива
У .NET Core/5+ рекомендується використовувати IHttpClientFactory замість Singleton для HttpClient, оскільки фабрика надає додаткові можливості (named clients, typed clients, middleware).

Приклад 2: Logging з Serilog

LoggerSingleton.cs
using Serilog;

namespace DesignPatterns.Creational.Singleton;

public sealed class LoggerSingleton
{
    private static readonly Lazy<ILogger> _lazyLogger = new(() =>
    {
        return new LoggerConfiguration()
            .MinimumLevel.Debug()
            .WriteTo.Console()
            .WriteTo.File("logs/app.log", rollingInterval: RollingInterval.Day)
            .CreateLogger();
    });

    public static ILogger Logger => _lazyLogger.Value;

    private LoggerSingleton() { }
}
Використання:
Services/UserService.cs
public class UserService
{
    public void CreateUser(string username)
    {
        LoggerSingleton.Logger.Information("Створення користувача: {Username}", username);

        // Логіка створення користувача

        LoggerSingleton.Logger.Debug("Користувач {Username} створений успішно", username);
    }
}

Patterns & Anti-patterns

✅ Best Practices

Крок 1: Використовуйте Lazy

Завжди віддавайте перевагу Lazy<T> для реалізації Singleton в C#.

private static readonly Lazy<MyClass> _instance = new(() => new MyClass());
public static MyClass Instance => _instance.Value;

Крок 2: Позначайте клас як sealed

Заборона успадкування критична для Singleton.

public sealed class MySingleton { }

Крок 3: Робіть конструктор приватним

Це єдиний спосіб заборонити зовнішнє створення екземплярів.

private MySingleton() { }

Крок 4: Дозволяйте Dependency Injection (опціонально)

Для кращої тестованості, розгляньте можливість створення інтерфейсу.

public interface IConfigurationManager { }
public sealed class ConfigurationManager : IConfigurationManager { }

// В Dependency Injection контейнері
services.AddSingleton<IConfigurationManager, ConfigurationManager>();

❌ Антипатерни та підводні камені

1. Глобальний стан (Global State)

Проблема:
Singleton створює глобальний змінюваний стан, що ускладнює розуміння потоку даних.
// АНТИПАТЕРН: Singleton зі змінюваним станом
public class GlobalState
{
    public static GlobalState Instance { get; } = new();

    public int Counter { get; set; } // Небезпечно!

    private GlobalState() { }
}

// Будь-яка частина коду може змінити Counter
GlobalState.Instance.Counter = 42;
Рішення:
Робіть стан незмінним (immutable) або використовуйте потокобезпечні колекції.
public class ImmutableConfig
{
    public static ImmutableConfig Instance { get; } = new();

    public IReadOnlyDictionary<string, string> Settings { get; }

    private ImmutableConfig()
    {
        Settings = new Dictionary<string, string>
        {
            ["ApiKey"] = "secret"
        }.AsReadOnly();
    }
}

2. Ускладнення тестування

Проблема:
Singleton створює приховані залежності, які неможливо замінити mock-об'єктами в тестах.
// АНТИПАТЕРН: Клас жорстко залежить від Singleton
public class UserService
{
    public void CreateUser(string name)
    {
        // Неможливо замінити DatabaseConnection на mock в тестах
        DatabaseConnection.Instance.ExecuteQuery($"INSERT INTO Users VALUES ('{name}')");
    }
}
Рішення:
Використовуйте Dependency Injection замість прямого доступу.
// ПРАВИЛЬНО: Залежність передається через конструктор
public class UserService
{
    private readonly IDatabaseConnection _db;

    public UserService(IDatabaseConnection db)
    {
        _db = db; // Можна передати mock в тестах
    }

    public void CreateUser(string name)
    {
        _db.ExecuteQuery($"INSERT INTO Users VALUES ('{name}')");
    }
}

// В тесті
var mockDb = new Mock<IDatabaseConnection>();
var service = new UserService(mockDb.Object);

3. Порушення Single Responsibility Principle (SRP)

Проблема:
Singleton виконує дві відповідальності:
  1. Бізнес-логіка класу
  2. Контроль над створенням екземпляру
Рішення:
Розгляньте використання DI контейнера (як Microsoft.Extensions.DependencyInjection), який візьме на себе контроль життєвого циклу.
// Замість Singleton патерну
services.AddSingleton<IConfigurationManager, ConfigurationManager>();

// Клас більше не контролює своє створення
public class ConfigurationManager : IConfigurationManager
{
    public ConfigurationManager() { } // Публічний конструктор
}

Troubleshooting

Проблема 1: Кілька екземплярів в багатопоточному середовищі

Симптоми:
var instance1 = MySingleton.GetInstance();
var instance2 = MySingleton.GetInstance();
Console.WriteLine(ReferenceEquals(instance1, instance2)); // False (!)
Причина:
Використовується наївна реалізація без блокування.Рішення:
Використовуйте Lazy<T> або Double-Checked Locking.
private static readonly Lazy<MySingleton> _instance = new(() => new MySingleton());
public static MySingleton Instance => _instance.Value;

Проблема 2: TypeInitializationException

Симптоми:
System.TypeInitializationException: The type initializer for 'MySingleton' threw an exception.
Причина:
Виняток у статичному конструкторі або при ініціалізації статичного поля.Рішення:
Додайте обробку помилок в конструкторі Singleton.
private DatabaseConnection()
{
    try
    {
        ConnectionString = ConfigurationManager.GetConnectionString();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Помилка ініціалізації: {ex.Message}");
        throw; // Перекидаємо виняток після логування
    }
}

Проблема 3: Проблеми з Reflection

Симптоми:
Singleton може бути обійдений через Reflection.
// Зламування Singleton через Reflection
var constructor = typeof(MySingleton).GetConstructor(
    BindingFlags.Instance | BindingFlags.NonPublic,
    null, Type.EmptyTypes, null);

var instance = (MySingleton)constructor.Invoke(null); // Новий екземпляр!
Рішення:
Додайте захист в приватному конструкторі.
public sealed class ProtectedSingleton
{
    private static readonly Lazy<ProtectedSingleton> _instance = new(() => new ProtectedSingleton());
    public static ProtectedSingleton Instance => _instance.Value;

    private ProtectedSingleton()
    {
        // Захист від Reflection
        if (_instance != null && _instance.IsValueCreated)
        {
            throw new InvalidOperationException("Використовуйте Instance властивість для доступу до Singleton.");
        }
    }
}

Практичні Завдання

Рівень 1: Базовий


Рівень 2: Середній


Рівень 3: Експертний


Підсумок

Ключові висновки

Що таке Singleton?

Породжуючий патерн, який гарантує єдиний екземпляр класу і надає глобальну точку доступу.

Коли використовувати?

Logger, Configuration Manager, Database Connection Pool, Cache — коли справді потрібен один екземпляр.

Як реалізувати в C#?

Lazy — найкращий підхід для thread-safe lazy initialization.

Головна небезпека

Глобальний стан, складність тестування, порушення SRP. Використовуйте обережно!

::

Альтернативи Singleton

АльтернативаКоли використовувати
Dependency Injection ContainerКоли потрібен контроль життєвого циклу зовні класу
Static ClassКоли не потрібен стан, лише статичні методи
Factory PatternКоли потрібна гнучкість створення різних реалізацій
Monostate PatternКоли потрібна поведінка Singleton без обмеження екземплярів

Посилання на наступні патерни

  • Builder — для створення складних об'єктів крок за кроком
  • Factory Method — для делегування створення об'єктів підкласам
  • Abstract Factory — для створення сімейств пов'язаних об'єктів

Додаткові ресурси