Skip to content

Глава 7.1: Паттерн «Одиночка» (Singleton)

Главная | Паттерн Singleton | Следующая: Модульные системы >>


Введение

Паттерн Singleton гарантирует, что класс имеет ровно один экземпляр, доступный глобально. В моддинге DayZ это наиболее распространённый архитектурный паттерн --- практически каждый менеджер, кэш, реестр и подсистема его использует. COT, VPP, Expansion, Dabs Framework и другие полагаются на синглтоны для координации состояния между скриптовыми слоями движка.

В этой главе рассмотрена каноническая реализация, управление жизненным циклом, когда паттерн уместен, и где он может привести к проблемам.


Содержание


Каноническая реализация

Стандартный синглтон DayZ следует простой формуле: поле private static ref, статический аксессор GetInstance() и статический DestroyInstance() для очистки.

c
class LootManager
{
    // Единственный экземпляр. 'ref' поддерживает его жизнь; 'private' предотвращает внешнее вмешательство.
    private static ref LootManager s_Instance;

    // Приватные данные, принадлежащие синглтону
    protected ref map<string, int> m_SpawnCounts;

    // Конструктор — вызывается ровно один раз
    void LootManager()
    {
        m_SpawnCounts = new map<string, int>();
    }

    // Деструктор — вызывается когда s_Instance устанавливается в null
    void ~LootManager()
    {
        m_SpawnCounts = null;
    }

    // Ленивый аксессор: создаёт при первом вызове
    static LootManager GetInstance()
    {
        if (!s_Instance)
        {
            s_Instance = new LootManager();
        }
        return s_Instance;
    }

    // Явная очистка
    static void DestroyInstance()
    {
        s_Instance = null;
    }

    // --- Публичный API ---

    void RecordSpawn(string className)
    {
        int count = 0;
        m_SpawnCounts.Find(className, count);
        m_SpawnCounts.Set(className, count + 1);
    }

    int GetSpawnCount(string className)
    {
        int count = 0;
        m_SpawnCounts.Find(className, count);
        return count;
    }
};

Почему private static ref?

Ключевое словоНазначение
privateПредотвращает установку s_Instance в null или его замену другими классами
staticОбщее для всего кода --- для доступа не нужен экземпляр
refСильная ссылка --- поддерживает объект живым, пока s_Instance не null

Без ref экземпляр был бы слабой ссылкой и мог быть собран сборщиком мусора, пока ещё используется.


Ленивая vs нетерпеливая инициализация

Ленивая инициализация (рекомендуемый вариант по умолчанию)

Метод GetInstance() создаёт экземпляр при первом обращении. Этот подход используется большинством модов DayZ.

c
static LootManager GetInstance()
{
    if (!s_Instance)
    {
        s_Instance = new LootManager();
    }
    return s_Instance;
}

Преимущества:

  • Никакой работы, пока реально не понадобится
  • Нет зависимости от порядка инициализации между модами
  • Безопасно, если синглтон необязателен (некоторые конфигурации сервера могут его никогда не вызвать)

Недостаток:

  • Первый вызывающий оплачивает стоимость конструирования (обычно незначительную)

Нетерпеливая инициализация

Некоторые синглтоны создаются явно при старте миссии, обычно из MissionServer.OnInit() или OnMissionStart() модуля.

c
// В вашем modded MissionServer.OnInit():
void OnInit()
{
    super.OnInit();
    LootManager.Create();  // Нетерпеливая: конструируется сейчас, а не при первом использовании
}

// В LootManager:
static void Create()
{
    if (!s_Instance)
    {
        s_Instance = new LootManager();
    }
}

Когда предпочесть нетерпеливую:

  • Синглтон загружает данные с диска (конфиги, JSON-файлы) и вы хотите, чтобы ошибки загрузки проявились при запуске
  • Синглтон регистрирует обработчики RPC, которые должны быть на месте до подключения любого клиента
  • Порядок инициализации важен и вам нужно контролировать его явно

Управление жизненным циклом

Наиболее частая причина багов синглтонов в DayZ --- отсутствие очистки при завершении миссии. Серверы DayZ могут перезапускать миссии без перезапуска процесса, что означает, что статические поля переживают перезапуск миссии. Если вы не обнулите s_Instance в OnMissionFinish, вы перенесёте устаревшие ссылки, мёртвые объекты и висячие обратные вызовы в следующую миссию.

Контракт жизненного цикла

Запуск процесса сервера
  └─ MissionServer.OnInit()
       └─ Создать синглтоны (нетерпеливо) или позволить им создаться самим (лениво)
  └─ MissionServer.OnMissionStart()
       └─ Синглтоны начинают работу
  └─ ... сервер работает ...
  └─ MissionServer.OnMissionFinish()
       └─ DestroyInstance() для каждого синглтона
       └─ Все статические ссылки установлены в null
  └─ (Миссия может перезапуститься)
       └─ Свежие синглтоны создаются снова

Паттерн очистки

Всегда снабжайте синглтон методом DestroyInstance() и вызывайте его при завершении:

c
class VehicleRegistry
{
    private static ref VehicleRegistry s_Instance;
    protected ref array<ref VehicleData> m_Vehicles;

    static VehicleRegistry GetInstance()
    {
        if (!s_Instance) s_Instance = new VehicleRegistry();
        return s_Instance;
    }

    static void DestroyInstance()
    {
        s_Instance = null;  // Сбрасывает ref, деструктор выполняется
    }

    void ~VehicleRegistry()
    {
        if (m_Vehicles) m_Vehicles.Clear();
        m_Vehicles = null;
    }
};

// В вашем modded MissionServer:
modded class MissionServer
{
    override void OnMissionFinish()
    {
        VehicleRegistry.DestroyInstance();
        super.OnMissionFinish();
    }
};

Паттерн централизованного завершения

Мод-фреймворк может объединить очистку всех синглтонов в MyFramework.ShutdownAll(), который вызывается из modded MissionServer.OnMissionFinish(). Это предотвращает распространённую ошибку забытого синглтона:

c
// Концептуальный паттерн (централизованное завершение):
static void ShutdownAll()
{
    MyRPC.Cleanup();
    MyEventBus.Cleanup();
    MyModuleManager.Cleanup();
    MyConfigManager.DestroyInstance();
    MyPermissions.DestroyInstance();
}

Когда использовать синглтоны

Хорошие кандидаты

Случай использованияПочему синглтон подходит
Классы-менеджеры (LootManager, VehicleManager)Ровно один координатор для предметной области
Кэши (кэш CfgVehicles, кэш иконок)Единый источник истины избегает избыточных вычислений
Реестры (реестр обработчиков RPC, реестр модулей)Центральный поиск должен быть глобально доступен
Хранители конфигурации (настройки сервера, права доступа)Одна конфигурация на мод, загружается один раз с диска
Диспетчеры RPCЕдиная точка входа для всех входящих RPC

Плохие кандидаты

Случай использованияПочему нет
Данные по игрокамОдин экземпляр на игрока, а не один глобальный
Временные вычисленияСоздать, использовать, уничтожить --- глобальное состояние не нужно
UI-представления / диалогиНесколько могут сосуществовать; используйте стек представлений
Компоненты сущностейПривязаны к отдельным объектам, а не глобально

Примеры из реальных модов

COT (Community Online Tools)

COT использует модульный паттерн синглтона через фреймворк CF. Каждый инструмент --- синглтон JMModuleBase, зарегистрированный при запуске:

c
// Паттерн COT: CF автоматически создаёт модули, объявленные в config.cpp
class JM_COT_ESP : JMModuleBase
{
    // CF управляет жизненным циклом синглтона
    // Доступ через: JM_COT_ESP.Cast(GetModuleManager().GetModule(JM_COT_ESP));
}

VPP Admin Tools

VPP использует явный GetInstance() в классах-менеджерах:

c
// Паттерн VPP (упрощённый)
class VPPATBanManager
{
    private static ref VPPATBanManager m_Instance;

    static VPPATBanManager GetInstance()
    {
        if (!m_Instance)
            m_Instance = new VPPATBanManager();
        return m_Instance;
    }
}

Expansion

Expansion объявляет синглтоны для каждой подсистемы и подключается к жизненному циклу миссии для очистки:

c
// Паттерн Expansion (упрощённый)
class ExpansionMarketModule : CF_ModuleWorld
{
    // CF_ModuleWorld сам является синглтоном, управляемым модульной системой CF
    // ExpansionMarketModule.Cast(CF_ModuleCoreManager.Get(ExpansionMarketModule));
}

Потокобезопасность

Enforce Script однопоточный. Всё выполнение скриптов происходит в основном потоке внутри игрового цикла движка Enfusion. Это означает:

  • Нет состояний гонки между параллельными потоками
  • Вам не нужны мьютексы, блокировки или атомарные операции
  • GetInstance() с ленивой инициализацией всегда безопасен

Однако реентерабельность всё ещё может вызывать проблемы. Если GetInstance() запускает код, который снова вызывает GetInstance() во время конструирования, вы можете получить частично инициализированный синглтон:

c
// ОПАСНО: реентерабельное конструирование синглтона
class BadManager
{
    private static ref BadManager s_Instance;

    void BadManager()
    {
        // Это вызывает GetInstance() во время конструирования!
        OtherSystem.Register(BadManager.GetInstance());
    }

    static BadManager GetInstance()
    {
        if (!s_Instance)
        {
            // s_Instance всё ещё null здесь во время конструирования
            s_Instance = new BadManager();
        }
        return s_Instance;
    }
};

Исправление --- присвоить s_Instance до выполнения любой инициализации, которая может вызвать повторный вход:

c
static BadManager GetInstance()
{
    if (!s_Instance)
    {
        s_Instance = new BadManager();  // Сначала присвоить
        s_Instance.Initialize();         // Затем выполнить инициализацию, которая может вызвать GetInstance()
    }
    return s_Instance;
}

Или, ещё лучше, избегайте циклической инициализации полностью.


Антипаттерны

1. Глобальное изменяемое состояние без инкапсуляции

Паттерн синглтона даёт глобальный доступ. Это не значит, что данные должны быть глобально записываемыми.

c
// ПЛОХО: Публичные поля приглашают к неконтролируемым изменениям
class GameState
{
    private static ref GameState s_Instance;
    int PlayerCount;         // Кто угодно может записать
    bool ServerLocked;       // Кто угодно может записать
    string CurrentWeather;   // Кто угодно может записать

    static GameState GetInstance() { ... }
};

// Любой код может сделать:
GameState.GetInstance().PlayerCount = -999;  // Хаос
c
// ХОРОШО: Контролируемый доступ через методы
class GameState
{
    private static ref GameState s_Instance;
    protected int m_PlayerCount;
    protected bool m_ServerLocked;

    int GetPlayerCount() { return m_PlayerCount; }

    void IncrementPlayerCount()
    {
        m_PlayerCount++;
    }

    static GameState GetInstance() { ... }
};

2. Отсутствие DestroyInstance

Если вы забудете очистку, синглтон сохраняется между перезапусками миссий с устаревшими данными:

c
// ПЛОХО: Нет пути для очистки
class ZombieTracker
{
    private static ref ZombieTracker s_Instance;
    ref array<Object> m_TrackedZombies;  // Эти объекты удаляются при завершении миссии!

    static ZombieTracker GetInstance() { ... }
    // Нет DestroyInstance() — m_TrackedZombies теперь содержит мёртвые ссылки
};

3. Синглтоны, которые владеют всем

Когда синглтон накапливает слишком много обязанностей, он становится «Объектом-Богом», о котором невозможно рассуждать:

c
// ПЛОХО: Один синглтон делает всё
class ServerManager
{
    // Управляет лутом И транспортом И погодой И спавном И банами И...
    ref array<Object> m_Loot;
    ref array<Object> m_Vehicles;
    ref WeatherData m_Weather;
    ref array<string> m_BannedPlayers;

    void SpawnLoot() { ... }
    void DespawnVehicle() { ... }
    void SetWeather() { ... }
    void BanPlayer() { ... }
    // 2000 строк спустя...
};

Разделите на сфокусированные синглтоны: LootManager, VehicleManager, WeatherManager, BanManager. Каждый небольшой, тестируемый и имеет чёткую предметную область.

4. Обращение к синглтонам в конструкторах других синглтонов

Это создаёт скрытые зависимости порядка инициализации:

c
// ПЛОХО: Конструктор зависит от другого синглтона
class ModuleA
{
    void ModuleA()
    {
        // Что если ModuleB ещё не создан?
        ModuleB.GetInstance().Register(this);
    }
};

Откладывайте межсинглтонную регистрацию в OnInit() или OnMissionStart(), где порядок инициализации контролируется.


Альтернатива: полностью статические классы

Некоторым «синглтонам» экземпляр вообще не нужен. Если класс не хранит состояние экземпляра и имеет только статические методы и статические поля, пропустите церемонию GetInstance() полностью:

c
// Экземпляр не нужен — всё статическое
class MyLog
{
    private static FileHandle s_LogFile;
    private static int s_LogLevel;

    static void Info(string tag, string msg)
    {
        WriteLog("INFO", tag, msg);
    }

    static void Error(string tag, string msg)
    {
        WriteLog("ERROR", tag, msg);
    }

    static void Cleanup()
    {
        if (s_LogFile) CloseFile(s_LogFile);
        s_LogFile = null;
    }

    private static void WriteLog(string level, string tag, string msg)
    {
        // ...
    }
};

Этот подход используется MyLog, MyRPC, MyEventBus и MyModuleManager в моде-фреймворке. Он проще, избегает накладных расходов на проверку null в GetInstance(), и делает намерение ясным: нет экземпляра, только общее состояние.

Используйте полностью статический класс когда:

  • Все методы не имеют состояния или работают со статическими полями
  • Нет осмысленной логики конструктора/деструктора
  • Вам никогда не нужно передавать «экземпляр» как параметр

Используйте настоящий синглтон когда:

  • Класс имеет состояние экземпляра, которому выгодна инкапсуляция (поля protected)
  • Вам нужен полиморфизм (базовый класс с переопределёнными методами)
  • Объект нужно передавать другим системам по ссылке

Чеклист

Перед выпуском синглтона проверьте:

  • [ ] s_Instance объявлен как private static ref
  • [ ] GetInstance() обрабатывает случай null (ленивая инициализация) или есть явный вызов Create()
  • [ ] DestroyInstance() существует и устанавливает s_Instance = null
  • [ ] DestroyInstance() вызывается из OnMissionFinish() или централизованного метода завершения
  • [ ] Деструктор очищает собственные коллекции (.Clear(), установка в null)
  • [ ] Нет публичных полей --- вся мутация идёт через методы
  • [ ] Конструктор не вызывает GetInstance() других синглтонов (отложите до OnInit())

Совместимость и влияние

  • Мультимод: Несколько модов, каждый определяющий собственные синглтоны, сосуществуют безопасно --- у каждого свой s_Instance. Конфликты возникают только если два мода определяют одинаковое имя класса, что Enforce Script отметит как ошибку переопределения при загрузке.
  • Порядок загрузки: Ленивые синглтоны не зависят от порядка загрузки модов. Нетерпеливые синглтоны, создаваемые в OnInit(), зависят от порядка цепочки modded class, который следует config.cpp requiredAddons.
  • Listen Server: Статические поля разделяются между контекстами клиента и сервера в одном процессе. Синглтон, который должен существовать только на серверной стороне, должен защитить конструирование проверкой GetGame().IsServer(), иначе он будет доступен (и потенциально инициализирован) из клиентского кода.
  • Производительность: Доступ к синглтону --- это статическая проверка на null + вызов метода, пренебрежимо малая нагрузка. Стоимость в том, что синглтон делает, а не в доступе к нему.
  • Миграция: Синглтоны переживают обновления версий DayZ, пока API, которые они вызывают (напр., GetGame(), JsonFileLoader), остаются стабильными. Для самого паттерна специальная миграция не нужна.

Распространённые ошибки

ОшибкаПоследствиеИсправление
Отсутствие вызова DestroyInstance() в OnMissionFinishУстаревшие данные и мёртвые ссылки на сущности переносятся между перезапусками миссий, вызывая вылеты или призрачное состояниеВсегда вызывайте DestroyInstance() из OnMissionFinish или централизованного ShutdownAll()
Вызов GetInstance() внутри конструктора другого синглтонаВызывает реентерабельное конструирование; s_Instance всё ещё null, создаётся второй экземплярОтложите межсинглтонный доступ в метод Initialize(), вызываемый после конструирования
Использование public static ref вместо private static refЛюбой код может установить s_Instance = null или заменить его, нарушая гарантию единственного экземпляраВсегда объявляйте s_Instance как private static ref
Отсутствие защиты нетерпеливой инициализации на listen-серверахСинглтон создаётся дважды (из серверного пути и из клиентского), если Create() не имеет проверки на nullВсегда проверяйте if (!s_Instance) внутри Create()
Накопление состояния без ограничений (безграничные кэши)Память растёт бесконечно на долгоживущих серверах; в итоге OOM или сильные лагиОграничьте коллекции максимальным размером или периодическим вытеснением в OnUpdate

Теория vs Практика

Учебник говоритРеальность DayZ
Синглтоны --- антипаттерн; используйте внедрение зависимостейВ Enforce Script нет DI-контейнера. Синглтоны --- стандартный подход для глобальных менеджеров во всех крупных модах.
Ленивой инициализации всегда достаточноОбработчики RPC должны быть зарегистрированы до подключения любого клиента, поэтому нетерпеливая инициализация в OnInit() часто необходима.
Синглтоны никогда не должны уничтожатьсяМиссии DayZ перезапускаются без перезапуска серверного процесса; синглтоны должны уничтожаться и создаваться заново при каждом цикле миссии.

Главная | Паттерн Singleton | Следующая: Модульные системы >>

Released under CC BY-SA 4.0 | Code examples under MIT License