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

Введение


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

Если почитать литературу на эту тему, то можно встретить различную критику данного подхода. Приведу список недостатков [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++: синглтон
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 31

    0
    SRP забыли.
    Приватный конструктор + френд на класс An забыли.
      0
      Френд на класс будет описан в последующих статьях по мере развития класса. Не совсем понятно, зачем нужен приватный конструктор?
        0
        Чтобы не создать синглтон как-то иначе.
          0
          An — это не класс-синглтон, его можно создавать как угодно. Синглтон заливается через функцию anFill. An можно копировать и иметь несколько экземпляров без каких-либо проблем.
        0
        SRP не упомянул, хотя можно было бы. На мой взгляд, данный факт является более теоретическим и является довольно спорным. Но, как несложно догадаться, приведенный подход решает также и эту проблему.
        –1
        «Паттерн» уже, конечно, в русскоязычной литературе определение устоявшееся, к сожалению, но «синглтон»… Лучше уж тогда родное название использовать.
          +5
          В русскоязычной литературе чаще используется слово синглтон, чем одиночка. Поэтому я посчитал, что будет более понятнее и естественнее использовать это слово.
          0
          Очень подробно про синглтон и его подводные камни для .Net: csharpindepth.com/Articles/General/Singleton.aspx
            0
            Данная статья немного о другом: в ней описываются различные реализации синглтонов, в то время как я хотел показать его использование.
            0
            Про многопоточность хорошо бы упомянуть сразу — ваш синглтон (иногда по книжке называемый синглтон Майерса) в таком виде не «threadsafe»…
              +1
              Будет про многопоточность, но позже. Невозможно охватить все аспекты в одной статье, т.к. хочется подробно описать такой важный вопрос. Я планирую продолжение этого топика с освещением вопросов про время жизни, многопоточность и различные плюшки.
                0
                Статья хорошая, просто рекомендую приписать, что в таком виде синглтон Майерса не «threadsafe» — там, где говорите, что многопоточность будет позже…
                  0
                  Поправил
              +1
              Когда мы получаем доступ к экземпляру класса, мы не знаем текущее состояние этого класса, и кто и когда его менял, и это состояние может быть вовсе не таким, как ожидается. Иными словами, корректность работы с синглтоном зависит от порядка обращений к нему, что вызывает неявную зависимость подсистем друг от друга и, как следствие, серьезно усложняет разработку.

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

              Нет?

              А за статью спасибо, интересно.
                0
                В целом да, но в базе данных есть один трюк. И хотя мы не знаем состояния, мы открываем транзакцию и на некоторое время фиксируем состояние грубо говоря. Потом начинаем выяснять это состояние (SELECT), и затем — изменять (INSERT,UPDATE,DELETE). Т.е. процесс происходит так, как будто мы ничего не знаем о текущем состоянии. При этом транзакция гарантирует, что никто не вмешается и не испортим нам получение состояния. Поэтому такая транзакционность упрощает взаимодействие с базой данных.

                В целом, не стоит воспринимать данный тезис как то, что происходит всегда без исключений. Но об этом все же стоит помнить при проектировании/использовании.
                0
                >>И хотя мы не знаем состояния, мы открываем транзакцию и на некоторое время фиксируем состояние грубо говоря. Потом начинаем выяснять это состояние (SELECT), и затем — изменять (INSERT,UPDATE,DELETE).

                Вы описали какой-то предельный случай. В жизни все несколько проще, никто не делает SELECT перед каждым INSERT.

                Дам контрпример.

                У меня есть объект логгера. Он один на приложение и даже на группу приложений. Знать его состояние для меня — избыточно, оно меня не волнует совершенно. Если что-то случится — он выбросит исключение.

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

                Резюме — понятие «состояние» имеет косвенное отношение к паттерну «одиночка».
                  0
                  Для логгера есть состояние, например — открытый файл. Если файл закрыт, то в лог писать нет смысла. К тому же файл могут удалить и логи перестанут записываться, если не сделать дополнительные приседания. В данном случае вызывающему объекту не нужно знать о состоянии, синглтон его должен поддерживать самостоятельно. Но об этом важно помнить.
                  0
                  Для логгера есть состояние, например — открытый файл. Если файл закрыт, то в лог писать нет смысла.

                  А зачем мне об этом вообще знать?
                  Надо будет — логгер бросит исключение. Не бросил — отлично, значит продолжаем.

                  Инкапсуляция же? Зачем свое состояние объект вообще должен кому-либо показывать? Есть интерфейсы, вот ими и пользуйтесь.
                    0
                    Да что я всё не на то сообщение-то отвечаю… Сорри.
                      0
                      Имелось в виду то, что у реализации синглтона есть состояние, но интерфейс, конечно же, должен по возможности скрывать подробности реализации и сложности, с которыми сталкивается такая реализация. В принципе, мы говорим об одном и том же.
                      0
                      Зачем там куча шаблонных классов, которые только запутывают понимание всего происходящего?
                      В чем преимущество Вашего решения пред следующим кодом?
                      //Singleton.h
                      class Singleton
                      {
                      private:
                          Singleton();
                          //disallow copy and assign
                          Singleton(const Singleton&);
                          Singleton& operator=(const Singleton&);
                      public:
                          Singleton& GetInstance();
                      };
                      .....
                      //Singleton.cpp
                      Singleton& Singleton::GetInstance()
                      {
                          static Singleton g_Instance;
                          return g_Instance;
                      }
                      Singleton::Singleton()
                      {
                      }
                      


                      Про шаблонный Singleton очень хорошо написано в книге Адександреску — Современное проектирование на c++
                      И кстати есть готовое решение — библиотека Loki
                        0
                        Ответ заключается в том, что такой подход содержит в себе все недостатки, перечисленные в начале статьи. Как следствие, при кажущейся простоте, появляется трудность при расширении и превращении синглтона не в синглтон. Например, мы знаем, что у нас есть один экран и поэтому логично использовать синглтон для отрисовки изображения. Но потом появляется второй экран и выясняется, что все надо переделывать. Предложенный подход не настаивает на использовании синглтона, и в следующих статьях я покажу, как можно это использовать.
                          0
                          Если предполагается заменять Singleton на не Singleton, то это явно ошибка в проектировании, и паттерн Singleton здесь ни при чем. Singleton это изначально один глобальный объект, он создается как один, и не предполагает наличия нескольких копий, это суть паттерна, которую нельзя нарушать!

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

                          Я видел в реальном проекте использование нескольких копий объекта Singleon, это было просто ужасно!
                            0
                            О том-то и речь — как использовать синглтон так, чтобы, если в будущем понадобится его заменить на что-то другое, не пришлось перелопачивать горы кода и заменять все использования синглтона.
                          0
                          как то у вас не согласуется факт чтения Александреску и приведенный код)
                          плюшка предложенного топикстартером решения — универсальность, мы один раз пишем реализацию синглтона и сотни раз ее используем с разными классами, в вашем же случае придется для каждого класса писать реализацию отдельно, это как минимум
                            0
                            По Вашему мнению человек, прочитавший Александреску должен тут-же накладывать тонны запутанного кода только лишь ради универсальности?
                            Универсальность — это удел библиотек, таких как буст и stl, к коим код данной стати, как говорится, и в подметки не годится.
                            Тот-же Александреску в другой своей книге пишет что «Главное — корректность, простота и ясность»
                            Стандарты проектирования на C++. 101 правило и рекомендации.

                            Книгу я привел в пример потому, что в ней рассматривается паттерн Singleton настолько детально, что мало ли что еще можно туда добавить.
                            Также решение, сделанное в библиотеке Loki (про которую и рассказывается в книге) довольно универсальное и сделанное на основе стратегий — можно настраивать детали реализации при помощи параметров шаблона, а не принимать компромиссные решения, навязываемые библиотекой, чего нет в данной статье.
                              0
                              Боюсь, вы не поняли основной посыл статьи. Речь в ней идет не о реализации, а об использовании. Собственно, это даже написано в заголовке. При этом реализацию можно взять из предложенного вами куска кода. Можно взять реализацию из Александреску. Книжка его очень умная и толковая. Но вся статья написана о том, как использовать и убрать недостатки, присущие этому паттерну.
                                0
                                Я и вправду не понимаю суть решения, по этому и задал вопрос.

                                Если можно вызывать An()->action(); в любом месте программы, то в чем его отличие от вызова GetInstance(), как это решает проблему декларации использования класса Singleton в произвольном классе?
                                Задекларировать использование Singleton в классе, можно при помощи ссылки либо указателя на Singleton, также как автор писал An x; в классе Y.
                                Какое принципиальное отличия между использованием представленного шаблона An<> и простого указателя (ссылки)?
                                Может быть в продолжении это будет как-то более понятно, но в контексте данной статьи шаблон An мне кажется спорным.

                                Но в любом случае, автору спасибо за статью, пишите еще!
                                  0
                                  Во второй статье будет более «выпукло», когда речь зайдет о времени жизни.
                        • UFO just landed and posted this here
                            0
                            А что по поводу тестируемости? Как сделать mock-объект при таком подходе? И как реализовать dependency injection?

                          Only users with full accounts can post comments. Log in, please.