Домашняя страница Паттерн Singleton в C++. Неуверенное Да, или категорическое Нет? Часть 1
Публикация
Отменить

Паттерн Singleton в C++. Неуверенное Да, или категорическое Нет? Часть 1

Это будет статья о преимуществах и недостатках паттерна Singleton в языке C++. До недавнего времени я пользовался подобными объектами постоянно. Если мне нужно было сделать какую то систему, с которой другой код мог бы работать, то я использовал именно его. Однако, в какой то момент я увидел всю несостоятельность этого паттерна для языка С++. В этой статье я хочу рассказать об этом и предложить более удобный и быстрый способ в работе способ организации кода.

Что такое Singleton

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

Далее приведен простейший пример реализации класса для паттерна:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Manager.h
/////////////////////////////
class Manager
{
public:
    void Do();

private:
    int  m_value;
};

extern Manager*    g_pManager;
bool    CreateManager();
void    DestroyManager();

// Manager.cpp
/////////////////////////////
#include "Manager.h"
void    Do()
{
   m_value = rand() %10;
}

Manager* g_pManager = NULL;
bool    CreateSceneManager()
{
    g_pManager= new cl_SceneManager;
    return g_pManager->f_Init();
}
void    DestroySceneManager()
{
    delete( g_pManager );
}

Теперь можно удобно использовать его функционал из любой точки кода. Достаточно написать g_pManager->Do() и желаемая функция будет вызвана. Однако какой ценой! Рассмотрим минусы данного способа организации кода, которые проявляются во время выполнения. Выполнение кода не оправдано тормозят следующие факторы:

  1. При каждом вызове функции будет разыменовываться указатель g_pManager.
  2. Поскольку вызывается функция класса, то в качестве первого параметра этой функции будет передаваться указатель на this.
  3. В каждой функции, где используется переменная класса, ее адрес динамически вычисляется как смещение относительно this. Поэтому в функции Manager::Do(), чтобы присвоить переменной класса новое значение нужно в реальном времени вычислить ее адрес. Конечно он всегда будет один и тот же, но будет вычисляться каждый раз.

Можно несколько улучшить результат, если написать код следующим образом:

1
2
3
4
// Manager.cpp
/////////////////////////////
Manager  g_Manager;
Manager* const g_pManager = &g_Manager;

Если включена опция Full Program Optimization, то компилятор ликвидирует пункты 1.1. и 1.3, заменив динамическое вычисление адресов на статическое, которое происходит при компиляции. Однако эта опция есть далеко не во всех компиляторах. В MS Visual Studio она конечно есть, однако если ваш проект кросплатформенный или изначально пишется для других платформ, то могут быть проблемы. Например компиляторы для консолей Xbox360 и PlayStation3 этой оптимизации делать не будут и все адреса будут вычисляться динамически.

Еще одним недостатком подобного кода является проблема неопределенности порядка инициализации глобальных объектов. Когда у нас есть динамически создаваемый объект, то мы можем гарантировать, что он будет создан в определенный момент и все необходимые системы к этому времени уже будут существовать. Когда же этот объект становится глобальным, то момент его инициализации не определен и могут возникнуть проблемы. Например, если этот объект имеет динамическое выделение памяти в конструкторе у себя или у любого из его членов (например имеет член класса std::vector, который выделяет память), а у вас есть свой менеждер памяти, который переопределяет оператор new и delete. Разумеется при запуске программы память будет выделена в обход вашего оператора new, но при ее завершении программа попытается освободить память вашей функцией delete, что приведет к неопределенному поведению программы (падению или утечкам памяти).

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Manager.h
/////////////////////////////
class I_Manager
{
public:
    virtual void Do();
};
extern I_Manager*  g_pManager;

// Manager_Win32.h
/////////////////////////////
#include "Manager.h"
class ManagerWin32 : public I_Manager
{
public:
    virtual void Do();
private:
    WinInt  m_value;
};
// Manager_PS3.h
/////////////////////////////
#include "Manager.h"
class ManagerPS3 : public I_Manager
{
public:
    virtual void Do();
private:
    PSInt  m_value;
};

// Manager_Win32.cpp
// Manager_PS3.cpp

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

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

1
2
3
4
5
6
7
8
// Manager.h
/////////////////////////////
const int ARRAY_SIZE = 10;
class Manager
{
...
    int  m_valueArray[ ARRAY_SIZE ];
};

Теперь при ее изменение будут перекомпилироваться все файлы .cpp, которые подключают заголовочный файл Manager.h. А если таких переменных много? А если тут объявлены еще и структуры, которые используются только в этом классе? При подобном проектировании, при изменение одного параметра, который должен был повлиять только на объект класса Manager, будет происходить очередное компилирование всего кода.

А если в класс мы захотим добавить переменные другого типа, причем не указатели на них, а именно переменные другого типа. То нам придется подключать в данном заголовочном файле другие файлы использую директиву include. Это значит, что если какой то код захочет использовать функциональность класса Manager, то ему придется подключить и другие файлы, от которых тот зависит. А это значит, что при изменение чего то в одном из них, произойдет перекомпилирование всей иерархии файлов.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
// Manager.h
/////////////////////////////
class Manager
{
...
#ifdef TARGET_WIN32
    WinInt  m_value;
#elif TARGET_PS3
    PSInt   m_value;
#else
    #error "Unsuported platform"
#endif
};

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

Альтернативу ищите в части 2 данной статьи…

Комментарии

maydjin, 9 марта 2012, 01:21

Начнем с того, что классическая реализация singletone на c++ выглядит приблизительно так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// header
class Manager
{
public:
 void doSomething();
 static Manager & instance();
private:
   Manager()
}
// implementation
Manager::Manager()
{
}
void Manager::doSomething()
{}
Manager & instance()
{
 //механизм доступа к обьекту,
 //как самой простой вариант :
   static Manager m;
   return m;
}

а глобальные переменные фу фу фу. Что касается проблем с производительностью, то при подобной реализации, остается только проблемма передачи указателя в функции члены, но это настолько не значительно, что если вы не пишете для кофеварок, этим можно принебречь(push/pop для указателя). Порядок инициализации, будет соблюден в случае статической переменной и все обьекты создаваемые на куче будут верно проинициализированны так же.

Ваш пример по поводу реализации кроссплатформенности тоже слегка надуман, кода возможно получится много (хотя не намного больше, чем в предлагаемом во второй части вами подходе), зато он получится более читабельным, к нему можно будет применять наследование, т.е будет меньше дублирование кода, да и поддреживать его будет строго легче.Да и использование динамического полиморфизма, для реализации кроссплатформенности вообще нафиг не нужно, вы вполне можете в этом убедится в книге «шаблоны c++ справочник разработчика», в главе где описывается статический полиморфизм, или у тогоже Александреску почитать про стратегии. Да и вопрос на засыпку (это уже тоже по поводу 2 части), как не используя реализацию синглтона с помощью обьекта, вы реализуете например возможность изменить его поведение пользователем(да да например совместно применить паттерн стратегия), имеется в виду пользователь программист? А так, вообще, конечно крассиво, убедительно, даже последователей уже обрели :)

FiloXSee, 9 марта 2012, 14:08

Спасибо за комментарий!

  1. По поводу «классической реализации» предложенной вами: реализаций можно придумать много и суть от этого не меняется. Каждая имеет свои плюсы и минусы. У меня в проектах есть жесткие требования по месту и порядку выделения памяти. Так что у меня используются две функции Create/Destroy и вызываются в определенном месте. Только раньше использовались синглетоны, а теперь функции неймспейсов.
  2. Я занимаюсь разработкой игр и производительность имеет значение. Смысл терять на каждом вызове функции, просто потому, что не хочется реорганизовать класс в функции (видимо люди перечитали книг по ООП и им всюду классы мерещатся) я не вижу.
  3. Платформо-независимый код нормально выносится в отдельный *.cpp файл (допустим есть файлы: Manager.h, Manager.cpp, Manager_PC.cpp, Manager_PS3.cpp..) так что дублирования кода не будет. Код для разных платформ редко похож, а если и так, то всегда можно его реорганизовать в систему с платформо-независимым интерфейсом и использовать дальше ее. Код или одинаковый (платформо-независимый) или разный и тогда наследование не поможет.
  4. На счет использования паттерна стратегия, как у Александреску: мой вариант действительно плохо подходит для него. Конечно, всегда можно инициализировать систему параметрами для настройки поведения. Это будет во время выполнения, а не во время компиляции. Я считаю, что методы нужно выбирать под задачу, а не использовать везде одно и тоже. Мой метод применим к менеджерам систем, фабрикам и т.п, которые должны быть в единственном экземпляре. Они крайне редко должны иметь разное поведение определенное во время компиляции. Для всех остальных случаев нужно использовать другие методы, тем более, если там будут реальные преимущества.
  5. Со времен написания статьи я еще одну проблему с синглетоном нашел. Допусти у вас есть в нем статический массив достаточно большой величины (10 мегабайт, например). Кроме этого массива в классе есть еще переменные, которые инициализируется в конструкторе не нулем.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// Manager.h
class Manager
{
  unsigned char    m_array[ 10 * 1024 * 1024 ];    // массив размером 10 мегабайт
  int              m_someValue;                    // переменная, которая в конструкторе инициализируется 1
};
extern Manager* g_pManager;

/// Manager.cpp
Manager  g_Manager;
Manager* g_pManager = &g_Manager;

//
Manager::Manager()
  : m_someValue( 1 )
{}

В этом случае — размер exe файла будет увеличен на размер этого массива (на все 10 мегабайт). Это происходит по тому, что инициализируются либо все члены класса, либо ни один. Если переменная m_someValue инициализировалась бы нулем, то размер exe файла был бы меньше на 10 мегабайт.

Это не просто плохо, а вообще некуда не годится. Зато, если все данные не будут членами класса, а будут свободными переменными, объявленными в *.cpp файле, то они будут инициализироваться по отдельности друг от друга и подобной проблемы не возникнет.

maydjin, 9 марта 2012, 18:57

Банальная перегрузка оператора new, позволит вам управлять временем и местом создания обьекта. Кстати не находите, что create/destroy ну уж как то очень напоминают new и delete ?:) Если операторы new/delete для вас не достаточно гибки используйте фабричный метод или абстрактную фабрику.

При разработке игр оптимизация такого уровня, ну практически бессмысленна, опять же если это игры не для кофеварок (а судя по информации в профиле для вполне себе производительных платформ). Тот же DirectX построен на основе COM, так что подобные «потери» вы несете при каждом вызове DirectX функции.

Выносится должен как раз платформо зависимый код, и он прекрасно выносится например при использовании техники d-указателей, которая например, повсеместно используется в Qt.

Паттерн, это именно паттерн а не готовое решение, и стратегию в c++ можно применять не только во время выполнения но и во время копиляции, о чем и написанно у Александреску, да и у Вандервуда тоже.

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

Вы наверное слышали что WinApi имеет обьектно ориентированный дизайн, так вот ваш подход, тоже не уводит от ООП парадигмы, вы просто реализуете его в стиле того же WinApI, т.е. по сути предлагаете писать на c а не на c++, с той лишь разницей что префиксы в названиях функций вы выносите в имя namespace, которых в с просто нет, иначе Microsoft сделала бы также, лет 30 назад… Если вам нужен чисто статичный функционал, то это не есть одиночка, и ничто не мешает вам описать класс только со статическими членами, эффект будет тот же, + возможности шаблонов и наследоваия.

FiloXSee, 10 марта 2012, 03:01

По поводу массива я поясню: допустим в вашей программе есть всего один класс со статическим массивом на 10 мегабайт и вы создаете статическую переменную данного класса (будущий синглетон, хотя это не важно. Суть только в том, что объект — статический). Вы компилируете приложение и получаете размер исполняемого файла 8 килобайт (для примера я взял пустое консольное приложение в VS2008). Теперь вы добавляете еще одну переменную в ваш класс и инициализируете ее не нулем. Размер исполняемого файла становится 10 мегабайт. Вы только что получили екзешник, 99.999% которого занято нулями. При старте программы действительно в памяти будет один и тот же массив в обоих случаях, но согласитесь, значительно увеличенный размер исполняемого файла замедлит загрузку да и распространять такую программу будет проблематично, когда вместо 8 килобайт нужно будет предлагать клиенту выкачивать 10 мегабайт.

Согласен, что пример синтетический. В действительности все будет еще хуже — таких коллекций, которые выделяют статические данные может быть много.

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

Кстати, при использовании d-указателей еще более возрастут расходы с каждым вызовом функции. Представьте какую-нибудь функцию SetRenderState которая будет вызваться сотни тысяч раз за кадр и всего лишь менять бит в какой-нибудь структуре и нечего больше. Использование d-указателей и/или использование наследования и виртуальных функций или просто функций класса — в несколько раз увеличит время выполнения кода по сравнению с простыми функциями (которые при желании можно еще и inline сделать). В общем случае d-указатели — это просто еще один прием программирования и бессмысленно обсуждать что лучше, в отрыве от конкретной задачи.

Согласен, что мой подход не уводит от ООП, а лишь заменяет реализацию. Я просто не вижу смысла использовать ООП в С++ стиле (через класс) везде, только потому, что в старых книжках написано, что ООП — это правильно. Советую почитать вот эту презентацию от Sony по поводу проблем с ООП. Pitfalls_of_Object_Oriented_Programming_GCAP_09.pdf

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

Публикация защищена лицензией CC BY 4.0 .

Oasis - солнечные зонты для Лос-Анжелеса

Паттерн Singleton в C++. Неуверенное Да, или категорическое Нет? Часть 2