Использование Шаблона Singleton



Введение Многие уже знакомы с термином синглтон.

Вкратце, это шаблон, описывающий объект, имеющий единственный экземпляр.

Создать такой экземпляр можно разными способами.

Но сейчас мы не будем об этом.

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

Я хотел бы рассказать вам о правильном использовании синглтона.

Если почитать литературу по этой теме, то можно встретить различную критику этого подхода.

я принесу тебе список недостатков [1] :

  1. Синглтон нарушает SRP (Принцип единой ответственности) — класс Singleton помимо выполнения своих непосредственных обязанностей еще и контролирует количество своих экземпляров.

  2. Зависимость обычного класса от синглтона не видна в публичном контракте класса.

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

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

  3. Глобальное государство.

    О вреде глобальных переменных вроде бы уже все знают, но тут у нас та же проблема.

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

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

  4. Наличие синглтона снижает тестируемость приложения в целом и классов, использующих синглтоны, в частности.

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

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

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



Выполнение

Первое, что хотелось бы отметить, это то, что синглтон — это реализация, а не интерфейс.

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

На словах выглядит хорошо, посмотрим, как должно выглядеть в жизни.

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

Его вообще не интересуют эти вопросы.

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

Интерфейс функциональности может быть либо абстрактным интерфейсом, либо конкретным классом.

В нашем конкретном случае это не имеет значения.

У нас есть идея, давайте реализуем ее на C++.

Здесь нам помогут шаблоны и возможность их специализации.

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

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

template<typename T> struct An { An() { clear(); } T* operator->() { return get0(); } const T* operator->() const { return get0(); } void operator=(T* t) { data = t; } bool isEmpty() const { return data == 0; } void clear() { data = 0; } void init() { if (isEmpty()) reinit(); } void reinit() { anFill(*this); } private: T* get0() const { const_cast<An*>(this)->init(); return data; } T* data; };

Описанный класс решает несколько задач.

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

Во-вторых, если экземпляра нет, вызывается функция anFill, которая заполняет необходимый экземпляр при его отсутствии (метод reinit).

При доступе к классу он автоматически инициализируется экземпляром и вызывается.

Давайте посмотрим на реализацию функции anFill:

template<typename T> void anFill(An<T>& a) { throw std::runtime_error(std::string("Cannot find implementation for interface: ") + typeid(T).

name()); }

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



Примеры использования

Теперь предположим, что у нас есть класс:

struct X { X() : counter(0) {} void action() { std::cout << ++ counter << ": in action" << std::endl; } int counter; };

Мы хотим сделать его синглтоном для использования в разных контекстах.

Для этого мы специализируем функцию anFill для нашего класса X:

template<> void anFill<X>(An<X>& a) { static X x; a = &x; }

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

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

Теперь мы можем использовать класс X следующим образом:

An<X> x; x->action();

Или проще:

An<X>()->action();

Что будет отображаться на экране:

1: in action

Когда мы снова вызовем действие, мы увидим:

2: in action

Это означает, что мы сохранили состояние и ровно один экземпляр класса X. Теперь немного усложним пример.

Для этого создадим новый класс Y, который будет содержать использование класса X:

struct Y { An<X> x; void doAction() { x->action(); } };

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

Y y; y.doAction();

Что после предыдущих вызовов отобразит:

3: in action

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

Это очень легко сделать:

X x; y.x = &x; y.doAction();

Те.

мы заполняем класс Y нашим (известным) экземпляром и вызываем соответствующую функцию.

На экране мы получим:

1: in action

Давайте теперь посмотрим на случай абстрактных интерфейсов.

Давайте создадим абстрактный базовый класс:

struct I { virtual ~I() {} virtual void action() = 0; };

Давайте определим две разные реализации этого интерфейса:

struct Impl1 : I { virtual void action() { std::cout << "in Impl1" << std::endl; } }; struct Impl2 : I { virtual void action() { std::cout << "in Impl2" << std::endl; } };

По умолчанию мы будем использовать первую реализацию Impl1:

template<> void anFill<I>(An<I>& a) { static Impl1 i; a = &i; }

Итак, следующий код:

An<I> i; i->action();

Даст вывод:

in Impl1

Давайте создадим класс, который использует наш интерфейс:

struct Z { An<I> i; void doAction() { i->action(); } };

Теперь мы хотим изменить реализацию.

Затем делаем следующее:

Z z; Impl2 i; z.i = &i; z.doAction();

Какие результаты:

in Impl2



Развитие идеи

В общем, на этом можно было бы закончить.

Однако стоит добавить несколько полезных макросов, чтобы облегчить жизнь:

#define PROTO_IFACE(D_iface) \ template<> void anFill<D_iface>(An<D_iface>& a) #define DECLARE_IMPL(D_iface) \ PROTO_IFACE(D_iface); #define BIND_TO_IMPL_SINGLE(D_iface, D_impl) \ PROTO_IFACE(D_iface) { a = &single<D_impl>(); } #define BIND_TO_SELF_SINGLE(D_impl) \ BIND_TO_IMPL_SINGLE(D_impl, D_impl)

Многие могут сказать, что макросы — это зло.

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

Макрос DECLARE_IMPL объявляет дополнение, отличное от заполнения по умолчанию.

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

Макрос BIND_TO_IMPL_SINGLE будет использоваться в файле CPP для реализации.

Он использует единственную функцию, которая возвращает экземпляр синглтона:

template<typename T> T& single() { static T t; return t; }

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

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

// IConfiguration.hpp struct IConfiguration { virtual ~IConfiguration() {} virtual int getConnectionsLimit() = 0; virtual void setConnectionLimit(int limit) = 0; virtual std::string getUserName() = 0; virtual void setUserName(const std::string& name) = 0; }; DECLARE_IMPL(IConfiguration) // Configuration.cpp struct Configuration : IConfiguration { Configuration() : m_connectionLimit(0) {} virtual int getConnectionsLimit() { return m_connectionLimit; } virtual void setConnectionLimit(int limit) { m_connectionLimit = limit; } virtual std::string getUserName() { return m_userName; } virtual void setUserName(const std::string& name) { m_userName = name; } private: int m_connectionLimit; std::string m_userName; }; BIND_TO_IMPL_SINGLE(IConfiguration, Configuration);

Затем вы можете использовать его в других классах:

struct ConnectionManager { An<IConfiguration> conf; void connect() { if (m_connectionCount == conf->getConnectionsLimit()) throw std::runtime_error("Number of connections exceeds the limit"); .

} private: int m_connectionCount; };



выводы

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

  2. Предоставление доступа к экземпляру синглтона и интерфейсу класса разделены на отдельные объекты.

    Таким образом каждый решает свою проблему, тем самым сохраняя SRP.

  3. Если у вас несколько конфигураций, вы можете без проблем загрузить нужный экземпляр в класс ConnectionManager.
  4. Тестируемость класса: можно сделать макет объекта и проверить, например, правильно ли работает условие при вызове метода подключения:

    struct MockConfiguration : IConfiguration { virtual int getConnectionsLimit() { return 10; } virtual void setConnectionLimit(int limit) { throw std::runtime_error("not implemented in mock"); } virtual std::string getUserName() { throw std::runtime_error("not implemented in mock"); } virtual void setUserName(const std::string& name) { throw std::runtime_error("not implemented in mock"); } }; void test() { // preparing ConnectionManager manager; MockConfiguration mock; manager.conf = &mock; // testing try { manager.connect(); } catch(std::runtime_error& e) { //.

    } }

Таким образом, описанный подход устраняет проблемы, указанные в начале статьи.

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



Литература

[1] Форум RSDN: список недостатков синглтона [2] Википедия: синглтон [3] Внутри C++: синглтон Теги: #C++ #singleton #шаблоны и практики #C++
Вместе с данным постом часто просматривают: