Синглтон (Singleton)

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

Несмотря на благородные намерения с которыми шаблон Одиночка (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 вся ранее возведенная архитектура рушится.

Компьютерная наука называет функции, не получающие доступ и не изменяющие глобальные состояния "чистыми (pure)" функциями. Чистые функции легче понять, легче оптимизировать на этапе компиляции и с ними можно делать такие удобные вещи как изоляция памяти, когда мы можем кешировать и повторно использовать результаты выполнения таких функций.

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

Если бы экземпляр аудио плеера не был бы объявлен глобальным, добавление #include с его заголовочным файлом так ничего бы и не дало. Этот факт счам по себе четко сказал бы новичку что эти модули не должны ничего знать друг о другие и ему нужно найти другой способ решения проблемы. Управляя доступом к экземпляру вы управляете связностью.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Подробнее про фрагментацию памяти написано в главе Пул Объектов ( Object Pool).

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

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

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

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

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

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

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

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

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

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

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

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 или подобным.

Некоторые используют для описания этого способа термин "инъекция зависимости(dependency injection)". Вместо того чтобы позволить коду выбираться наружу и становиться зависимым от каких либо глобальных вызовов, зависимости передаются внутрь кода через параметр.

А еще иногда используется инверсная "инъекция зависимости" для более сложных зависимостей кода.

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

  • Получение из базового класса. Во многих играх архитектура представляет собой неглубокую, но достаточно ветвистую иерархию. Зачастую всего с одним уровнем наследования. Например у вас может быть базовый класс GameObject, от которого наследуются классы для каждого врага или объекта в игре. При такой архитектуре большая часть игрового кода обитает в "листьях" унаследованного класса. Это значит что у всех классов есть доступ к одному и тому же базовому классу GameObject. Мы можем воспользоваться этим преимуществом:

Вещи наподобие логгинга, раскиданные по всей кодовой базе тоже обозначаются своим термином "сквозное связывание(cross-cutting concern)". Борьба со сквозным связыванием встречается в архитектуре постоянно, особенно в статически типизированных языках.

Аспектно-ориентированное программирование (Aspect-oriented programming) разработано как раз для такой концепции.

class GameObject
    {
    protected:
        Log& getLog() { return log_; }

    private:
        static Log& log_;
};

class Enemy : public GameObject
{
    void doSomething()
    {
        getLog().write("I can log!");
    }
};

Возникает вопрос "Как GameObject может получить экземпляр Log?" Проще всего будет иметь базовый класс и создавать его статический экземпляр. Если же вы не хотите чтобы ваш базовый класс играл такую активную роль, вы можете использовать другое популярное решение, т.е. инъекцию зависимости.

При этом внешний код дает экземпляр Log, который будет использовать наш GameObject. Помимо прочего это облегчает изоляцию GameObject от других объектов для тестирования. Более подробно об этом написано в главе Локатор службы(Service Locator).

При этом никто за пределами GameObject не может получить доступ к его объекту Log, а любая другая унаследованная сущность могут, с помощью getLog(). Этот шаблон позволяет полученным объектам реализовывать себя в терминах защищенных методов, которые описаны в главе Подкласс Песочница (Subclass Sandbox).

  • Получить через другой объект, который уже является глобальным. Цель убрать вообще все глобальные состояния конечно похвальна, но навряд ли практична. Большинство кодовых баз обязательно имеет хотя бы несколько глобальных объектов, например объекты Game или World, представляющие общее состояние игры.

    Вы можете обратить это себе на пользу и уменьшить количество глобальных объектов, нагрузив уже существующие. Вместо того чтобы делать синглтон из Log, FileSystem и 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 (например для стримминга или тестовых целей) Log, FileSystem и AudioPlayer останутся незатронутыми - они даже разницы не заметят. Есть и недостаток - в результате гораздо больше кода будет завязано на сам класс World. Если классу просто нужно проиграть звук, наш пример все равно требует от него знания о World для того чтобы получить аудио плеер.

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

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

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

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

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

Last updated