Pull to refresh

Использование паттерна синглтон

Reading time 7 min
Views 97K

Введение


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

Если почитать литературу на эту тему, то можно встретить различную критику данного подхода. Приведу список недостатков [1]:
  1. Синглтон нарушает SRP (Single Responsibility Principle) — класс синглтона, помимо того чтобы выполнять свои непосредственные обязанности, занимается еще и контролированием количества своих экземпляров.
  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

При повторном вызове 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;
};

Определим 2 различные реализации этого интерфейса:
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 файле для реализации. Он использует функцию single, которая возвращает экземпляр синглтона:
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. Тестируемость класса: можно сделать mock-объект и проверить, например, правильность работы условия при вызове метода connect:
    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] Inside C++: синглтон
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+14
Comments 31
Comments Comments 31

Articles