Одиночка (Singleton, Синглтон)

Одиночка (Singleton, Синглтон) — порождающий паттерн, который гарантирует, что для определенного класса будет создан только один объект, а также предоставит к этому объекту точку доступа.

Когда надо использовать Синглтон? Когда необходимо, чтобы для класса существовал только один экземпляр

Синглтон позволяет создать объект только при его необходимости. Если объект не нужен, то он не будет создан. В этом отличие синглтона от глобальных переменных.

Классическая реализация данного шаблона проектирования на C# выглядит следующим образом:

class Singleton
{
    private static Singleton instance;
 
    private Singleton()
    {}
 
    public static Singleton getInstance()
    {
        if (instance == null)
            instance = new Singleton();
        return instance;
    }
}

В классе определяется статическая переменная — ссылка на конкретный экземпляр данного объекта и приватный конструктор. В статическом методе getInstance() этот конструктор вызывается для создания объекта, если, конечно, объект отсутствует и равен null.

Для применения паттерна Одиночка создадим небольшую программу. Например, на каждом компьютере можно одномоментно запустить только одну операционную систему. В этом плане операционная система будет реализоваться через паттерн синглтон:

class Program
{
    static void Main(string[] args)
    {
        Computer comp = new Computer();
        comp.Launch("Windows 8.1");
        Console.WriteLine(comp.OS.Name);
         
        // у нас не получится изменить ОС, так как объект уже создан    
        comp.OS = OS.getInstance("Windows 10");
        Console.WriteLine(comp.OS.Name);
         
        Console.ReadLine();
    }
}
class Computer
{
    public OS OS { get; set; }
    public void Launch(string osName)
    {
        OS = OS.getInstance(osName);
    }
}
class OS
{
    private static OS instance;
 
    public string Name { get; private set; }
 
    protected OS(string name)
    {
        this.Name=name;
    }
 
    public static OS getInstance(string name)
    {
        if (instance == null)
            instance = new OS(name);
        return instance;
    }
}

Синглтон и многопоточность

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

static void Main(string[] args)
{
    (new Thread(() =>
    {
        Computer comp2 = new Computer();
        comp2.OS = OS.getInstance("Windows 10");
        Console.WriteLine(comp2.OS.Name);
 
    })).Start();
 
    Computer comp = new Computer();
    comp.Launch("Windows 8.1");
    Console.WriteLine(comp.OS.Name);
    Console.ReadLine();
}

Здесь запускается дополнительный поток, который получает доступ к синглтону. Параллельно выполняется тот код, который идет запуска потока и кторый также обращается к синглтону. Таким образом, и главный, и дополнительный поток пытаются инициализровать синглтон нужным значением — «Windows 10», либо «Windows 8.1». Какое значение сиглтон получит в итоге, пресказать в данном случае невозможно.

Вывод программы может быть такой:

Windows 8.1
Windows 10

Или такой:

Windows 8.1
Windows 8.1

В итоге мы сталкиваемся с проблемой инициализации синглтона, когда оба потока одновременно обращаются к коду:

if (instance == null)
    instance = new OS(name);

Чтобы решить эту проблему, перепишем класс синглтона следующим образом:

class OS
{
    private static OS instance;
 
    public string Name { get; private set; }
    private static object syncRoot = new Object();
 
    protected OS(string name)
    {
        this.Name = name;
    }
 
    public static OS getInstance(string name)
    {
        if (instance == null)
        {
            lock (syncRoot)
            {
                if (instance == null)
                    instance = new OS(name);
            }
        }
        return instance;
    }
}

Чтобы избежать одновременного доступа к коду из разных потоков критическая секция заключается в блок lock.

Другие реализации синглтона

Выше были рассмотрены общие стандартные реализации: потоконебезопасная и потокобезопасная реализации паттерна. Но есть еще ряд дополнительных реализаций, которые можно рассмотреть.

Потокобезопасная реализация без использования lock

public class Singleton
{
    private static readonly Singleton instance = new Singleton();
 
    public string Date { get; private set; }
 
    private Singleton()
    {
        Date = System.DateTime.Now.TimeOfDay.ToString();
    }
 
    public static Singleton GetInstance()
    {
        return instance;
    }
}

Данная реализация также потокобезопасная, то есть мы можем использовать ее в потоках так:

(new Thread(() =>
{
    Singleton singleton1 = Singleton.GetInstance();
    Console.WriteLine(singleton1.Date);
})).Start();
 
Singleton singleton2 = Singleton.GetInstance();
Console.WriteLine(singleton2.Date);

Lazy-реализация

Определение объекта синглтона в виде статического поля класса открывает нам дорогу к созданию Lazy-реализации паттерна Синглтон, то есть такой реализации, где данные будут инициализироваться только перед непосредственным использованием. Поскольку статические поля инициализируются перед первым доступом к статическому членам класса и перед вызовом статического конструктора (при его наличии). Однако здесь мы можем столкнуться с двумя трудностями.

Во-первых, класс синглтона может иметь множество статических переменных. Возможно, мы вообще не будем обращаться к объекту синглтона, а будем использовать какие-то другие статические переменные:

public class Singleton
{
    private static readonly Singleton instance = new Singleton();
    public static string text = "hello";
    public string Date { get; private set; }
         
    private Singleton()
    {
        Console.WriteLine($"Singleton ctor {DateTime.Now.TimeOfDay}");
        Date = System.DateTime.Now.TimeOfDay.ToString();
    }
 
    public static Singleton GetInstance()
    {
        Console.WriteLine($"GetInstance {DateTime.Now.TimeOfDay}");
        Thread.Sleep(500);
        return instance;
    }
}
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine($"Main {DateTime.Now.TimeOfDay}");
        Console.WriteLine(Singleton.text);
        Console.Read();
    }
}

В данном случае идет только обращение к переменной text, однако статическое поле instance также будет инициализировано. Например, консольный вывод в данном случае мог бы выглядеть следующим образом:

Singleton ctor 16:05:54.1469982
Main 16:05:54.2920316
hello

В данном случае мы видим, что статическое поле instance инициализировано.

Для решения этой проблемы выделим отдельный внутренний класс в рамках класса синглтона:

public class Singleton
{
    public string Date { get; private set; }
    public static string text = "hello";
    private Singleton()
    {
        Console.WriteLine($"Singleton ctor {DateTime.Now.TimeOfDay}");
        Date = DateTime.Now.TimeOfDay.ToString();
    }
 
    public static Singleton GetInstance()
    {
        Console.WriteLine($"GetInstance {DateTime.Now.TimeOfDay}");
        return Nested.instance;
    }
 
    private class Nested
    {
        internal static readonly Singleton instance = new Singleton();
    }
}
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine($"Main {DateTime.Now.TimeOfDay}");
        Console.WriteLine(Singleton.text);
        Console.Read();
    }
}

Теперь статическая переменная, которая представляет объект синглтона, определена во вложенном классе Nested. Чтобы к этой переменной можно было обращаться из класса синглтона, она имеет модификатор internal, в то же время сам класс Nested имеет модификатор private, что позволяет гарантировать, что данный класс будет доступен только из класса Singleton.

Консольный вывод в данном случае мог бы выглядеть следующим образом:

Main 16:11:40.1320873
hello

Далее мы сталкиваемся со второй проблемой: статические поля инициализируются перед первым доступом к статическому членам класса и перед вызовом статического конструктора (при его наличии). Но когда именно? Если класс содержит статические поля, не содержит статического конструктора, то время инициализации статических полей зависит от реализации платформы. Нередко это непосредственно перед первым использованием, но тем не менее момент точно не определен — это может быть происходить и чуть раньше. Однако если класс содержит статический конструктор, то статические поля будут инициализироваться непосредственно либо при создании первого экземпляра класса, либо при первом обращении к статическим членам класса.

Например, рассмотрим выполнение следующей программы:

static void Main(string[] args)
{
    Console.WriteLine($"Main {DateTime.Now.TimeOfDay}");
    Console.WriteLine(Singleton.text);
 
    Singleton singleton1 = Singleton.GetInstance();
    Console.WriteLine(singleton1.Date);
    Console.Read();
}

Ее возможный консольный вывод:

Main 16:33:33.1404818
hello
Singleton ctor 16:33:33.1564802
GetInstance 16:33:33.1574824
16:33:33.1564802

Мы видим, что код метода GetInstance, который идет до вызова конструктора класса Singleton, выполняется после выполнения этого конструктора. Поэтому добавим в выше определенный класс Nested статический конструктор:

public class Singleton
{
    public string Date { get; private set; }
    public static string text = "hello";
    private Singleton()
    {
        Console.WriteLine($"Singleton ctor {DateTime.Now.TimeOfDay}");
        Date = DateTime.Now.TimeOfDay.ToString();
    }
 
    public static Singleton GetInstance()
    {
        Console.WriteLine($"GetInstance {DateTime.Now.TimeOfDay}");
        Thread.Sleep(500);
        return Nested.instance;
    }
 
    private class Nested
    {
        static Nested() { }
        internal static readonly Singleton instance = new Singleton();
    }
}

Теперь при выполнении той же программы мы получим полноценную Lazy-реализацию:

Main 16:37:18.4108064
hello
GetInstance 16:37:18.4208062
Singleton ctor 16:37:18.4218065
16:37:18.4228061

Реализация через класс Lazy<T>

Еще один способ создания синглтона представляет использование класса Lazy<T>:

public class Singleton
{
    private static readonly Lazy<Singleton> lazy = 
        new Lazy<Singleton>(() => new Singleton());
 
    public string Name { get; private set; }
         
    private Singleton()
    {
        Name = System.Guid.NewGuid().ToString();
    }
     
    public static Singleton GetInstance()
    {
        return lazy.Value;
    }
}

Некоторые подробности на хабре

Подробнее

Эта глава — настоящая аномалия. Все остальные главы демонстрируют как нужно использовать шаблоны. Эта глава показывает как их использовать не нужно.

Несмотря на благородные намерения с которыми шаблон Синглтон (Singleton pattern )GOF создавался бандой четырех., обычно вреда от него больше чем пользы. Несмотря на их призыв не злоупотреблять этим шаблоном, это послание как-то потерялось прежде чем попало в игровую индустрию.

Как и в случае с любым другим шаблоном, использование его в неподходящем месте также уместно как лечение пулевого ранения пластырем. Так как используется он слишком часто, большая часть главы будет посвящена тому где стоит избегать использование синглтона. Но для начала познакомимся с самим шаблоном.

Шаблон Синглтон

Паттерны программирования определяют Синглтон следующим образом:

Обеспечивает существование единственного экземпляра класса и обеспечивает глобальный доступ к нему.

Давайте проведем разделение на этом «и» и рассмотрим обе половины по отдельности.

Ограничение экземпляров класса до одного

Иногда класс может работать корректно только если существует его единственный экземпляр. Зачастую это необходимо если класс взаимодействует с внешней системой, которая поддерживает собственное глобальное состояние.

Представим себе класс-обертку над низкоуровневым API файловой системы. Так как файловые операции занимают определенное время, наш класс выполняет операции асинхронно. Это значит что несколько операций могут выполняться конкурентно и следовательно им нужно координировать свою работу. Если мы начнем с вызова для создания файла и затем попытаемся удалить тот же самый файл, наш класс-обертка должен знать об обеих вызовах чтобы они друг другу не мешали.

Чтобы это сделать, наш класс-обертка должен иметь доступ ко всем предыдущим операциям. Если пользователь может свободно создавать любое количество экземпляров этого класса, каждый из этих экземпляров не будет значить ничего об операциях, выполняемых другими экземплярами. Вот здесь то и нужен Синглтон. Он позволяет обеспечить еще на этапе компиляции возможность существования только одного экземпляра класса.

Обеспечение глобальной области видимости

Наш класс-оболочку над файловой системой использует несколько других систем в игре: система отчетов, загрузка контента, сохранение состояния игры и т.д. Если эти системы не смогут создать собственный экземпляр обертки над файловой системой, то как они смогут ее использовать?

Синглтон решает и эту проблему. Он не только создает единственный экземпляр класса, но и делает его глобально видимым. Таким образом кто угодно и где угодно может наложить на него свою лапу. Целиком классическая реализация выглядит следующим образом:

class FileSystem
{
public:
static FileSystem& instance()
{
// Ленивая инициализация.
if (instance_ == NULL) instance_ = new FileSystem();
return *instance_;
}

private:
FileSystem() {}

static FileSystem* instance_;
};

Статический член instance_ хранит экземпляр класса, а приватный конструктор обеспечивает то что этот экземпляр единственный. Публичный и статический метод instance() предоставляет доступ к экземпляру для всей остальной кодовой базы. А еще он отвечает за создание экземпляра синглтона методом ленивой инициализации, т.е. в момент первого вызова.

Современный вариант выглядит следующим образом:

class FileSystem
{
public:
static FileSystem& instance()
{
static FileSystem *instance = new FileSystem();
return *instance;
}

private:
FileSystem() {}
};

С++11 гарантирует что инициализация локальной статической переменной происходит только один раз, даже в случае конкурентного доступа. Поэтому, при условии что у вас есть поддерживающий C++11 компилятор, такой код является потоково-безопасным. А вот первый пример — нет.

Зачем мы его используем

Похоже у нас есть победитель. Наша обертка над файловой системой доступна всегда и везде и ее не нужно передавать с помощью каких либо ухищрений. Сам класс достаточно умен для того чтобы не позволить нам по ошибке устроить месиво из кучи его экземпляров. А вот и еще несколько приятных особенностей:

  • Экземпляр не будет создан если его никто не захочет использовать. Экономия памяти и циклов процессора — это всегда хорошо. Благодаря тому что синглтон инициализируется при первом вызове, его экземпляр не создастся если никто в игре к нему не обратится.
  • Инициализация во время выполнения. Очевидной альтернативой Синглтону является класс ос статическими переменными членами. Мне нравятся простые решения, да и использование статических классов вместо синглтона возможно. Однако у статических членов есть одно ограничение: автоматическая инициализация. Компилятор инициализирует статические переменные до вызова main(). Это значит что они не могут использовать информацию, которая будет известна только после того как программа запустится и начнет работать (например, когда будет загружена файл настроек). А еще это значит что они не могут полагаться друг на друга — компилятор не гарантирует очередности в которой относительно друг друга статические переменные будут инициализированы.

    Ленивая инициализация решает обе эти проблемы. Синглтон будет инициализирован настолько поздно, насколько возможно, так что ко времени его создания нужная информация уже будет загружена. И если это не приводит к циклической зависимости, один синглтон может ссылаться при инициализации на другой.
  • У вас может быть подкласс синглтон. Это очень мощная, но редко используемая возможность. Скажем например, что мы хотим сделать свою обертку над файловой системой кросс-платформенной. Чтобы это заработало нам нужно сделать интерфейс файловой системы абстрактным с подклассами, которые будут реализовывать интерфейсы для каждой платформы. Вот эти базовые классы.
class FileSystem
{
public:
virtual ~FileSystem() {}
virtual char* readFile(char* path) = 0;
virtual void writeFile(char* path, char* contents) = 0;
};

class PS3FileSystem : public FileSystem
{
public:
virtual char* readFile(char* path)
{
// Файловая система Sony IO API...
}

virtual void writeFile(char* path, char* contents)
{
// Файловая система Sony IO API...
}
};

class WiiFileSystem : public FileSystem
{
public:
virtual char* readFile(char* path)
{
// Файловая система Nintendo IO API...
}

virtual void writeFile(char* path, char* contents)
{
// Файловая система Nintendo IO API...
}
};

А теперь превращаем FileSystem в синглтон:

class FileSystem
{
public:
static FileSystem& instance();

virtual ~FileSystem() {}
virtual char* readFile(char* path) = 0;
virtual void writeFile(char* path, char* contents) = 0;

protected:
FileSystem() {}
};

Хитрость здесь заключается в создании экземпляра:

FileSystem& FileSystem::instance()
{
#if PLATFORM == PLAYSTATION3
static FileSystem *instance = new PS3FileSystem();
#elif PLATFORM == WII
static FileSystem *instance = new WiiFileSystem();
#endif

return *instance;
}
  • С помощью простого переключателя компилятора мы связываем обертку файловой системы с конкретным типом. И теперь вся наша кодовая база может получать доступ к файловой системе через FileSystem::instance() не привязываясь ни к какому платформозависимому коду. Вместо этого получившаяся связность инкапсулируется внутри реализации самого класса FileSystem.

Итак мы подошли к тому пределу, до которого мы обычно доходим при решении подобных проблем. У нас есть обертка вокруг файловой системы. Она работает. Она доступна глобально в любом месте где может понадобиться. Пришло время проверить код, налить себе любимый напиток и начать праздновать.

Почему мы можем пожалеть что стали его использовать

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

Это глобальная переменная

Когда игры еще писались несколькими парнями в гаражах, борьба с железом была гораздо важнее чем башни из слоновой кости программной архитектуры. Кодеры старой школы на C и assembler свободно использовали глобальные и статические переменные и создавали отличные игры. Но как только игры стали больше и сложнее, архитектура и сложность ее поддержки стали превращаться в бутылочное горлышко. Создавать игры стало сложнее не из-за аппаратных ограничений, а из-за производственных ограничений.

Мы перебрались на языки наподобие C++ и стали постигать мудрость, накопленную нашими программистами-предшесвтенниками. Важнейшим усвоенным уроком насчет глобальных переменных было то что использовать глобальные переменные — плохо. По нескольким причинам:

  • Они делают код менее понятным. Допустим мы ищем баг в функции, написанной кем-то другим. Если функция не затрагивает никаких глобальных состояний, мы можем ограничиться рамками функции и просто изучить какие аргументы она получает.

    А теперь представьте что прямо в середине функции происходит обращение к SomeClass::getSomeGlobalData(). Теперь для того чтобы понять что происходит нам нужно прошерстить всю кодовую базу и выяснить кто еще работает с этими глобальными данными. И у вас конечно нет причин ненавидеть глобальные переменные пока однажды вам не придется просмотреть миллион строк кода в три часа утра в поисках того глючного вызова, который все таки записывает в статическую глобальную переменную некорректное значение.
  • Они усиливают связность. Новый программист в вашей команде конечно еще не знаком с вашей прекрасной , легко поддерживаемой архитектурой игры, но ему нужно выполнить задание: добавить проигрывание звуков, когда камни падают на землю. Мы с вами хорошо понимаем что нам нужно любой ценой не допускать лишних связей между физической и аудио подсистемами, но новичок просто хочет выполнить свое задание. К нашему несчастью экземпляр AudioPlayer имеет глобальную область видимости. И вот после добавления всего одного #include вся ранее возведенная архитектура рушится.


      Если бы экземпляр аудио плеера не был бы объявлен глобальным, добавление #include с его заголовочным файлом так ничего бы и не дало. Этот факт счам по себе четко сказал бы новичку что эти модули не должны ничего знать друг о другие и ему нужно найти другой способ решения проблемы. Управляя доступом к экземпляру вы управляете связностью.
    1. Они не конкурентно-дружественны. Деньки, когда игра работала на одноядерном процессоре уже сочтены. Современный код должен по крайней мере корректно работать в многопоточной системе, даже если он не использует все ее преимущества. Когда мы делаем что либо глобальным, у нас образуется область памяти, видимая всеми потоками. И каждый поток может к ней обратится не зная что возможно с этой памятью уже работает кто-то еще. Это приводит к блокировкам (deadlocks), гонкам за доступ(race conditions) и другим страшным багам синхронизации.

    Все эти ужасы должны отпугнуть нас от использования глобальных переменных в целом и шаблона Синглтон в частности, но они никак не приближают нас к понимают того как нам нужно проектировать игру. Как стоить архитектуру игры без глобального состояния?

    На этот вопрос можно отвечать только развернуто (большая часть книги как раз этому и посвящена) и этот ответ совсем не тривиален и не очевиден. А еще нам ведь нужно выпустить игру. Шаблон Синглтон выглядит как панацея. А у нас книга об объектно-ориентированных шаблонах проектирования. Так что он должен казаться вполне архитектурным, верно? Ведь он позволяет нам проектировать программы также как и десятилетия раньше.

    К сожалению это скорее плацебо, а не лекарство. Если вы еще раз просмотрите список проблем, порождаемых глобальными переменными, вы заметите что Синглтон ни одну из них не решает. А все потому что синглтон всего лишь глобальное состояние, инкапсулированное внутри класса.

    Он решает две проблемы даже если у вас всего одна

    Слово «и» в описании Синглтона от банды четырех выглядит немного странным. Решает этот шаблон одну или сразу две проблемы? Что если у нас только одна из этих проблем? Обеспечение наличия всего одной копии экземпляра может быть полезно, но кто сказал что мы хотим чтобы кто угодно мог пользоваться этим экземпляром? И наоборот глобальная видимость может быть полезной, но хочется иметь возможность иметь множество экземпляров.

    Вторая из проблем — удобство доступа — обычно и побуждает нас к использованию шаблона Синглтон. Представим себе класс журналирования — логгер. Большинство модулей в игре только выиграют от возможности легко добавлять в лог диагностическую информацию. В то же время передача единственного экземпляра логгера в каждый класс будет только перегружать код и отвлекать от его основного назначения.

    Очевидным решением является преобразования класса Log в синглтон. В результате каждая функция может получить экземпляр класса напрямую. Но в то же самое время у нас появляется новое ограничение. Внезапно мы лишаемся возможности иметь больше одного класса логгера.

    По началу это и не проблемы вовсе. Мы пишем всего в один лог файл и нам всеравно не нужно больше одного экземпляра. А дальше в процессе разработки у нас появляются новые трудности. Каждый член команды использует логгер в своих нуждах и вскоре лог файл превращается в месиво данных. Программистам приходится пролистывать полотка сообщений чтобы найти интересующее.

    Мы могли бы решить эту проблему добавив возможность писать в несколько лог файлов. Для этого можно завести отдельный логгер для всех областей игры: сети, пользовательского интерфейса, аудиоподстистемы, геймплея. Но мы не можем этого сделать. Класс Log не только не позволяет нам больше иметь несколько экземпляров класса, но и налагает такое ограничение на все свои вызовы:


    Log::instance().write("Some event.");

    Теперь чтобы вернуть себе возможность иметь одновременно несколько экземпляров логгера (как было изначально), нм нужно изменить не только сам класс, но и все участки кода где он используется. Наш удобный доступ перестал быть удобным.

    Ленивая инициализация отнимает у вас контроль над происходящим

    В мире настольных PC, где полно виртуальной памяти и мягкие системные требования, ленивая инициализация — благо. Игры — это другое дело. Инициализация системы требует времени: на выделение памяти, на загрузку ресурсов и т.д. Если инициализация аудио подсистемы требует несколько сотен миллисекунд, нам нужно иметь возможность контролировать когда эта инициализация произойдет. Если мы позволим ей лениво инициализироваться при первом проигрывании звука — это произойти в середине напряженной игры и вызовет неслабое падение FPS и заикание геймплея.

    Кроме этого играм обычно требуется надежный контроль размещения объектов в куче чтобы избежать фрагментации памяти. Если аудио система выделяет при инициализации область в памяти из кучи, нам нужно понимать когда произойдет инициализация чтобы проконтролировать где в куче будет размещена эта область.

    Из за двух этих проблем, большинство игр, которые я видел не полагаются на ленивую инициализацию. Вместо этого они реализуют Синглтон следующим образом:

    class FileSystem {
      public:
        static FileSystem & instance() {
          return instance_;
        }
    
      private:
        FileSystem() {}
    
      static FileSystem instance_;
    };

    Таким образом мы решаем проблему ленивой инициализации, но делаем это за счет потери некоторых особенностей синглтона, которые делают его лучше чем простая глобальная переменная. После добавление статического экземпляра мы больше не можем использовать полиморфизм и класс должен стать конструируемым во время статической инициализации. И еще мы теперь не можем освободить память, занимаемую экземпляром когда он станет не нужным.

    Вместо создания синглтона, все что мы получили — это простой статический класс. Это не обязательно плохо, но если нам нужен просто статический класс, то почему бы вообще не избавиться от мет ода instance() и пользоваться статической функцией напрямую? Вызов Foo::bar() проще чем Foo::instance().bar() и к тому же яснее показывает что мы имеем дело со статической памятью.

    Что можно сделать вместо этого

    Если я хорошо справился со своей задачей, вы теперь дважды подумаете прежде чем в следующий раз вытащить Синглтон из ящика с инструментами для решения своей проблемы. Но у вас по прежнему осталась неразрешенная проблема. А каким же инструментом тогда следует пользоваться? В зависимости от того что вам нужно сделать у меня есть для вас несколько вариантов, но сначала…

    Подумайте нужен ли вам класс вообще

    Большинство синглтонов, которые я видел в играх были «менеджерами»: это были просто такие туманные классы, созданные только для того чтобы нянчиться с другими объектами. Я помню кодовые базы где практически у каждого класса был свой менеджер: Монстр, Менеджер монстров, Частица, Менеджер частиц, Звук, Менеджер звуков, Менеджер менеджеров. Иногда в имени встречались слова «система» или «движок», но сама суть не менялась.

    Иногда классы смотрители конечно полезны, но зачастую они просто демонстрируют незнание ООП. Полюбуйтесь на эти два надуманных класса:

    class Bullet {
      public:
        int getX() const {
          return x_;
        }
      int getY() const {
        return y_;
      }
    
      void setX(int x) {
        x_ = x;
      }
      void setY(int y) {
        y_ = y;
      }
    
      private:
        int x_, y_;
    };
    
    class BulletManager {
      public:
        Bullet * create(int x, int y) {
          Bullet * bullet = new Bullet();
          bullet - > setX(x);
          bullet - > setY(y);
    
          return bullet;
        }
    
      bool isOnScreen(Bullet & bullet) {
        return bullet.getX() >= 0 &&
          bullet.getX() < SCREEN_WIDTH &&
          bullet.getY() >= 0 &&
          bullet.getY() < SCREEN_HEIGHT;
      }
    
      void move(Bullet & bullet) {
        bullet.setX(bullet.getX() + 5);
      }
    };

    Может этот пример и довольно туп, но я видел достаточно много кода, архитектурная сущность которого после небольшого упрощения сводилась к тому же. Если посмотреть на код выше, вполне логично предположить что BulletManager можно сделать синглтоном. В конце концов все где встречаются пули, будет нуждаться и в их менеджере, так что сколько еще экземпляров менеджера нам нужно?

    Правильный ответ — ниcколько. И вот как мы решим проблему «синглтона» для нашего класса менеджера:

    class Bullet {
      public:
        Bullet(int x, int y): x_(x), y_(y) {}
    
      bool isOnScreen() {
        return x_ >= 0 && x_ < SCREEN_WIDTH &&
          y_ >= 0 && y_ < SCREEN_HEIGHT;
      }
    
      void move() {
        x_ += 5;
      }
    
      private:
        int x_, y_;
    };

    Вот и все. Нет менеджера — нет проблемы. Плохо спроектированные синглтоны — это чаще всего просто «вспомогательные» классы, добавляющие функциональность к другим классам. Везде где это возможно просто переносите поведение внутрь самого класса. В конце концов ООП именно о том чтобы позволить классу заботиться о себе самостоятельно.

    Вне менеджера, тем не менее, могут существовать и другие проблемы, которые можно решить синглтоном. И для каждой из этих проблем есть альтернативные решения, которые нужно иметь в виду.

    Ограничение класса единственным экземпляром

    Это ровно половина того что дает нам шаблон Синглтон. И как в нашем примере с файловой системой, такое ограничение на наличие единственного экземпляра класса может быть критичны.

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

    Нам может понадобиться обеспечить существование единственного экземпляра без предоставления глобального доступа. Есть несколько вариантов решения. Вот один из них:

    class FileSystem {
      public:
        FileSystem() {
          assert(!instantiated_);
          instantiated_ = true;
        }
    
        ~FileSystem() {
          instantiated_ = false;
        }
    
      private:
        static bool instantiated_;
    };
    
    bool FileSystem::instantiated_ = false;

    Класс позволяет кому угодно вызвать его конструктор, но по прежнему содержит проверку и выдает ошибку если вы попытаетесь создать второй экземпляр. И когда правильный код создаст экземпляр первым, мы можем быть уверены что никто больше другой экземпляр не создаст и доступа к нему не получит. Класс обеспечивает существование себя в единственном экземпляре, но не навязывает нам того как с ним нужно обращаться.

    Недостатком этого примера является то что проверка на существование экземпляра выполняется во время выполнения. А вот шаблон Синглтон самой своей сущностью гарантирует существование только одного экземпляра класс уже на этапе компиляции.

    Удобство доступа к экземпляру

    Удобство доступа — одна из причин, почему мы выбираем синглтон. С его помощью очень просто получить доступ к объекту, который может понадобиться нам в самых различных местах. К сожалению эта простота имеет свою цену: доступ к объекту слишком просто получить даже оттуда, откуда нам не хотелось бы.

    Общее правило такое: нам желательна как можно более узкая видимость переменных, но без ущерба для функциональности. Чем уже видимость объекта, тем реже нам нужно задумываться о его влиянии на другие части кода в работе. Прежде чем применять подход дробовика в виде объекта синглтона с глобальной областью видимости, давайте посмотрим какие еще варианты доступа кодовой базы к объекту у нас есть:

    • Прямая передача. Простейшее решение и в некоторых случая х самое лучшее. Мы просто передаем объект в качестве аргумента в функцию, которая в нем нуждается. Не стоит сгоряча отбрасывать этот подход за его громоздкость.

      Представьте себе функцию рендеринга объекта. Чтобы отрендерить объект, нужно получить доступ к объекту, представляющему собой устройство видеовывода и поддерживающему состояния рендера. Очень часто все это передается целиком в функции рендеринга через параметр с именем context или подобным.


        С другой стороны, некоторые объекты не соответствуют контексту метода. Например, функции управляющей AI полезно иметь возможность писать в лог, но это совсем не основная ее задача. Поэтому передача в эту функцию объекта Log в качестве аргумента будет смотреться по крайней мере странно и в таком случае лучше выбрать другой вариант.
      1. Получение из базового класса. Во многих играх архитектура представляет собой неглубокую, но достаточно ветвистую иерархию. Зачастую всего с одним уровнем наследования. Например у вас может быть базовый класс GameObject, от которого наследуются классы для каждого врага или объекта в игре. При такой архитектуре большая часть игрового кода обитает в «листьях» унаследованного класса. Это значит что у всех классов есть доступ к одному и тому же базовому классу GameObject. Мы можем воспользоваться этим преимуществом:
        class GameObject {
          protected:
            Log & getLog() {
              return log_;
            }
        
          private:
            static Log & log_;
        };
        
        class Enemy: public GameObject {
          void doSomething() {
            getLog().write("I can log!");
          }
        };
        • При этом никто за пределами GameObject не может получить доступ к его объекту Log , а любая другая унаследованная сущность могут, с помощью getLog(). Этот шаблон позволяет полученным объектам реализовывать себя в терминах защищенных методов, которые описаны в главе подкласс Песочница (Subclass Sandbox).
        • Получить через другой объект, который уже является глобальным. Цель убрать вообще все глобальные состояния конечно похвальна, но навряд ли практична. Большинство кодовых баз обязательно имеет хотя бы несколько глобальных объектов, например объекты Game или World, представляющие общее состояние игры.

          Вы можете обратить это себе на пользу и уменьшить количество глобальных объектов, нагрузив уже существующие. Вместо того чтобы делать синглтон из LogFileSystem и AudioPlayer можно поступить таким образом:
        class World {
          public:
            static World & instance() {
              return instance_;
            }
        
          // Функции для указания log_, и всего остального ...
        
          Log & getLog() {
            return *log_;
          }
          FileSystem & getFileSystem() {
            return *fileSystem_;
          }
          AudioPlayer & getAudioPlayer() {
            return *audioPlayer_;
          }
        
          private:
            static World instance_;
        
          Log * log_;
          FileSystem * fileSystem_;
          AudioPlayer * audioPlayer_;
        };
        • Здесь глобальным объектом является только World. Функции могут получить доступ к другим системам через него:


          World::instance().getAudioPlayer().play(VERY_LOUD_BANG);



            Если позднее архитектуру придется изменить и добавить несколько экземпляров World ( например для стримминга или тестовых целей) LogFileSystem и AudioPlayer останутся незатронутыми — они даже разницы не заметят. Есть и недостаток — в результате гораздо больше кода будет завязано на сам класс World . Если классу просто нужно проиграть звук, наш пример все равно требует от него знания о World для того чтобы получить аудио плеер.

            Выходом может быть гибридное решение. Код, уже знающий о World может получать через него прямой доступ к AudioPlayer. А код, который о нем не знает может получать доступ к AudioPlayer с помощью другого решения о которых мы уже говорили.
          1. Получение через Локатор службы(Service Locator). До сих пор мы предполагали что глобальный класс — это обязательно какой-то конкретный класс наподобие World. Но есть еще и вариант при котором мы определяем класс, весь смысл которого будет заключаться в предоставлении глобального доступа к объектам. Этот шаблон называется Локатор службы(Service Locator) и мы его обсудим в отдельной главе.

          Что же остается на долю Синглтона

          Наш вопрос так и остался без ответа. Где же стоит применять шаблон Синглтон? Честно говоря я никогда не использовал в игре чистую реализацию от банды четырех. Для обеспечения единственности экземпляра я предпочитаю использовать статический класс. Если это не работает, я использую статический флаг для проверки во время выполнения что экземпляр класса уже создан.

          Еще нам могут помочь некоторые другие главы книги. Шаблон подкласс Песочница (Subclass Sandbox) дает нескольким экземплярам доступ к разделяемому состоянию, не делая его глобально доступным. Локатор службы(Service Locator) не только делает объект глобально доступным, но еще и предоставляет вам дополнительную гибкость в плане настройки объекта.

          Добавить комментарий

          Ваш адрес email не будет опубликован. Обязательные поля помечены *