Pull to refresh

Синглтон и время жизни объекта

Reading time 9 min
Views 16K
Эта статья является продолжением моей первой статьи “Использование паттерна синглтон” [0]. Сначала я хотел все, что связано со временем жизни, изложить в этой статье, но объем материала оказался велик, поэтому решил разбить ее на несколько частей. Это — продолжение целого цикла статей про использование различных шаблонов и методик. Данная статья посвящена времени жизни и развитию использования синглтона. Перед прочтением второй статьи настоятельно рекомендуется ознакомиться с моей первой статьей [0].

В предыдущей статье была использована следующая реализация для синглтона:
template<typename T>
T& single()
{
    static T t;
    return t;
}


Функция single возвращала нам заветный синглтон. Однако данный подход имеет изъян: в этом случае мы не контролируем время жизни объекта и он может удалиться в тот момент, когда мы хотим этим объектом воспользоваться. Поэтому следует использовать другой механизм создания объекта, используя оператор new.

Так уж получилось, что в языке C++ отсутствует сборщик мусора, поэтому необходимо следить за созданием и уничтожением объекта. И хотя эта проблема уже давно известна и даже понятны методы как ее решать, подобного рода ошибки не редкий гость в наших программах. В целом можно выделить следующие виды ошибок, которые делают программисты:
  1. Использование памяти при не созданном объекте.
  2. Использование памяти уже удаленного объекта.
  3. Неосвобождение памяти, занимаемой объектом.

Как следствие таких ошибок программа либо начинать «течь», либо начинает вести себя непредсказуемо, либо просто «падает». Можно, конечно, поговорить о том, что хуже, но ясно одно: подобные ошибки являются достаточно серьезными.

На примере синглтона можно с легкостью показать, как делаются такие ошибки. Открываем статью в Википедии [1] и находим реализацию для C++:
class OnlyOne
{
public:
    static OnlyOne* Instance()
    {
        if(theSingleInstance==0)
            theSingleInstance=new OnlyOne;
        return theSingleInstance;
    }
private:
    static OnlyOne* theSingleInstance;
    OnlyOne(){};
};
OnlyOne* OnlyOne::theSingleInstance=0;

Видно, что для синглтона память выделяется, однако по какой-то причине не освобождается. Можно, конечно, сказать, что для синглтона это не является серьезной проблемой, так как его время жизни совпадает с временем работы программы. Однако тут есть ряд но:
  1. Программы по поиску утечек памяти будут все время показывать эти утечки для синглтонов.
  2. Синглтоном может быть достаточно сложный объект, обслуживающий открытый конфигурационный файл, связь с базой данных и проч. Неправильное уничтожение таких объектов может вызывать проблемы.
  3. Все начинается с малого: сначала не следим за памятью для синглтонов, а потом и для остальных объектов.
  4. И главные вопрос: зачем делать неправильно, если можно сделать правильно?

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

Анализируя исходный код различных программных продуктов я для себя выделил еще 2 важных правила:
  1. Не использовать new.
  2. Не использовать delete.

Тут стоит немного пояснить, что я имею в виду. Понятно, что где-то все равно будет вызываться new и delete. Речь про то, что это должно находиться строго в одном месте, лучше в одном классе, чтобы это не распылять по программе. Тогда, при правильной организации такого класса, не надо будет следить за временем жизни объектов. И я сразу скажу, что это возможно! Стоит сразу оговориться, что такой подход мне нигде не встречался. Так что будем своего рода первооткрывателями.

Умные указатели


К счастью, в C++ есть замечательное средство, которое называется «умный указатель». Их умность заключается в том, что, хотя они и ведут себя как обычные указатели, при этом контролируют время жизни объектов. Для этого они используют счетчик, который самостоятельно подсчитывает количество ссылок на объект. При достижении счетчиком нуля объект автоматически уничтожается. Будем использовать умный указатель из стандартной библиотеки std::shared_ptr заголовочного файла memory. Стоит отметить, что такой класс доступен для современных компиляторов, которые поддерживают стандарт C++0x. Для тех, кто использует старый компилятор, можно использовать boost::shared_ptr. Интерфейсы у них абсолютно идентичны.

Возложим на наш класс An следующие обязанности:
  1. Контроль времени жизни объектов, используя умные указатели.
  2. Создание экземпляров, в том числе и производных классов, не используя операторов new в вызывающем коде.


Этим условиям удовлетворяет следующая реализация:
template<typename T>
struct An
{
    template<typename U>
    friend struct An;

    An()                              {}

    template<typename U>
    An(const An<U>& a) : data(a.data) {}

    template<typename U>
    An(An<U>&& a) : data(std::move(a.data)) {}

    T* operator->()                   { return get0(); }
    const T* operator->() const       { return get0(); }
    bool isEmpty() const              { return !data; }
    void clear()                      { data.reset(); }
    void init()                       { if (!data) reinit(); }
    void reinit()                     { anFill(*this); }
    
    T& create()                       { return create<T>(); }

    template<typename U>
    U& create()                       { U* u = new U; data.reset(u); return *u; }
    
    template<typename U>
    void produce(U&& u)               { anProduce(*this, u); }

    template<typename U>
    void copy(const An<U>& a)         { data.reset(new U(*a.data)); }

private:
    T* get0() const
    {
        const_cast<An*>(this)->init();
        return data.get();
    }

    std::shared_ptr<T> data;
};


Стоит остановиться поподробнее на предложенной реализации:
  1. Конструктор использует move-семантику [6] из C++0x стандарта для увеличения быстродействия при копировании.
  2. Метод create создает объект нужного класса, по умолчанию создается объект класса T.
  3. Метод produce создает объект в зависимости от принимаемого значения. Назначение этого метода будет описано позднее.
  4. Метод copy производит глубокое копирование класса. Стоит отметить, что для копирования в качестве параметра необходимо указывать тип реального экземпляра класса, базовый тип не подходит.


При этом синглтон перепишется в следующем виде:
template<typename T>
struct AnAutoCreate : An<T>
{
    AnAutoCreate()     { create(); }
};

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

template<typename T>
An<T> anSingle()
{
    return single<AnAutoCreate<T>>();
}


Вспомогательные макросы будут такими:
#define PROTO_IFACE(D_iface, D_an)    \
    template<> void anFill<D_iface>(An<D_iface>& D_an)

#define DECLARE_IMPL(D_iface)    \
    PROTO_IFACE(D_iface, a);

#define BIND_TO_IMPL(D_iface, D_impl)    \
    PROTO_IFACE(D_iface, a) { a.create<D_impl>(); }

#define BIND_TO_SELF(D_impl)    \
    BIND_TO_IMPL(D_impl, D_impl)

#define BIND_TO_IMPL_SINGLE(D_iface, D_impl)    \
    PROTO_IFACE(D_iface, a) { a = anSingle<D_impl>(); }

#define BIND_TO_SELF_SINGLE(D_impl)    \
    BIND_TO_IMPL_SINGLE(D_impl, D_impl)

#define BIND_TO_IFACE(D_iface, D_ifaceFrom)    \
    PROTO_IFACE(D_iface, a) { anFill<D_ifaceFrom>(a); }

#define BIND_TO_PROTOTYPE(D_iface, D_prototype)    \
    PROTO_IFACE(D_iface, a) { a.copy(anSingle<D_prototype>()); }


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

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


Теперь рассмотрим использование описанного класса для реализации синглтона:
// header file
struct X
{
    X()            { x = 1; }
    
    int x;
};
// декларация заливки реализации
DECLARE_IMPL(X)

// cpp file
struct Y : X
{
    Y()            { x = 2; }
        
    int y;
};
// связывание декларации X и реализации Y используя синглтон
BIND_TO_IMPL_SINGLE(X, Y)


Теперь это можно использовать следующим образом:
An<X> x;
std::cout << x->x << std::endl;


Что на экране даст цифру 2, т.к. для реализации использовался класс Y.

Контроль времени жизни


Рассмотрим теперь пример, который показывает важность использования умных указателей для синглтонов. Для этого разберем следующий код:
struct A
{
    A()  { std::cout << "A" << std::endl; a =  1; }
    ~A() { std::cout << "~A" << std::endl; a = -1; }
    
    int a;
};

struct B
{
    B()  { std::cout << "B" << std::endl; }
    ~B() { std::cout << "~B" << std::endl; out(); }
    
    void out() { std::cout << single<A>().a << std::endl; }
};


Теперь посмотрим, что выведется на экран при таком вызове функции out:
single<B>().out();

// вывод на экран
B
A
1
~A
~B
-1

Разберемся, что здесь происходит. В самом начале мы говорим, что хотим реализацию класса B, взятую из синглтона, поэтому создается класс B. Затем вызываем фукнцию out, которая берет реализацию класса A из синглтона и берет значение a. Величина a задается в конструкторе A, поэтому на экране появится цифра 1. Теперь программа заканчивает свою работу. Объекты начинают уничтожатся в обратной последовательности, т.е. сначала разрушается класс A, созданный последним, а потом разрушается класс B. При разрушении класса B мы снова зовем фукнцию out из синлтона, но т.к. объект A уже разрушен, то на экране мы видим надпись -1. Вообще говоря, программа могла и рухнуть, т.к. мы используем память уже разрушенного объекта. Таким образом, данная реализация показывает, что без контроля времени жизни программа может благополучно упасть при завершении.

Давайте теперь посмотрим, как можно сделать то же самое, но с контролем времени жизни объектов. Для этого будем использовать наш класс An:
struct A
{
    A()    { std::cout << "A" << std::endl; a = 1; }
    ~A()   { std::cout << "~A" << std::endl; a = -1; }
    
    int a;
};
// связывание декларации A с собственной реализацией используя синглтон
BIND_TO_SELF_SINGLE(A)

struct B
{
    An<A> a;
    
    B()    { std::cout << "B" << std::endl; }
    ~B()   { std::cout << "~B" << std::endl; out(); }
    
    void out() { std::cout << a->a << std::endl; }
};
// связывание декларации B с собственной реализацией используя синглтон
BIND_TO_SELF_SINGLE(B)

// код
An<B> b;
b->out();


Данный код практически ничем не отличается от предыдущего, за исключением следующих важных деталей:
  1. Объекты A и B используют класс An для синглтонов.
  2. Класс B явно декларирует зависимость от класса A, используя соответствующий публичный член класса (подробнее об этом подходе можно узнать из предыдущей статьи).


Посмотрим, что теперь выведется на экран:
B
A
1
~B
1
~A

Как видно, теперь мы продлили время жизни класса A и изменили последовательность уничтожения объектов. Отсутствие значения -1 говорит о том, что объект существовал во время доступа к его данным.

Итого


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

P.S.


Многие спрашивают, а в чем, собственно, смысл? Почему нельзя просто сделать синглтон? Зачем использовать какие-то дополнительные конструкции, которые ясности не добавляют, а лишь усложняют код. В принципе, при внимательном прочтении первой статьи [0] уже можно понять, что данный подход более гибок и устраняет ряд существенных недостатков синглтона. В следующей статье будет отчетливо понятно, зачем я это так расписывал, т.к. в ней речь уже будет идти не только о синглтоне. А через статью будет вообще понятно, что синглтон тут абсолютно не при чем. Все, что я пытаюсь показать — это использование Dependency inversion principle [4] (см. также The Principles of OOD [5]). Собственно, именно после того, как я увидел впервые этот подход на Java, мне стало обидно, что в C++ это слабо используют (в принципе, есть фреймворки, которые предоставляют подобный функционал, но хотелось бы чего-то более легковесного и практичного). Приведенная реализация — это лишь маленький шажок в этом направлении, который уже приносит огромную пользу.

Также хотелось бы отметить еще несколько вещей, отличающих приведенную реализацию от классического синглтона (вообще говоря, это следствия, но они важны):
  1. Класс, описывающий синглтон, можно использовать в нескольких экземплярах без каких-либо ограничений.
  2. Синглтон заливается неявно посредством функции anFill, которая контролирует количество экземпляров объекта, при этом можно использовать конкретную реализацию вместо синглтона при необходимости (показано в первой статье [0]).
  3. Есть четкое разделение: интерфейс класса, реализация, связь между интерфейсом и реализацией. Каждый решает только свою задачу.
  4. Явное описание зависимостей от синглтонов, включение этой зависимости в контракт класса.


Update


Почитав комментарии я понял, что есть некоторые моменты, которые стоит прояснить, т.к. многие не знакомы с принципом обращения зависимостей (dependency inversion principle, DIP или inversion of control, IoC). Рассмотрим следующий пример: у нас есть база данных, в которой содержится необходимая нам информация, например список пользователей:
struct IDatabase
{
    virtual ~IDatabase() {}

    virtual void beginTransaction() = 0;
    virtual void commit() = 0;
    ...
};

У нас есть класс, который выдает нужную нам информацию, в том числе и необходимого пользователя:
struct UserManager
{
    An<IDatabase> aDatabase;
    
    User getUser(int userId)
    {
        aDatabase->beginTransaction();
        ...
    }
};

Здесь мы создаем член aDatabase, который говорит о том, что ему необходима некая база данных. Ему не важно знать, что это будет за база данных, ему не нужно знать, кто и когда это будет заполнять/заливать. Но класс UserManager знает, что ему туда зальют то, что нужно. Все, что он говорит, это: «дайте мне нужную реализацию, я не знаю какую, и я сделаю все, что вам нужно от этой базы данных, например, предоставлю необходимую информацию о пользователе из этой базы данных».

Теперь мы делаем хитрый трюк. Так как у нас есть одна лишь база данных, которая содержит всю нашу информацию, то мы говорим: ок, раз есть только одна база данных, давайте сделаем синглтон, и чтобы не париться каждый раз в заливке реализации, сделаем так, чтобы синглтон сам заливался:
struct MyDatabase : IDatabase
{
    virtual void beginTransaction();
    ...
};
BIND_TO_IMPL_SINGLE(IDatabase, MyDatabase)

Т.е. мы создаем реализацию MyDatabase и говорим, что для синглтона будем использовать именно ее, используя макрос BIND_TO_IMPL_SINGLE. Тогда следующий код автоматически будет использовать MyDatabase:
UserManager manager;
User user = manager.getUser(userId);

С течением времени оказалось, что у нас есть еще одна база данных, в которой тоже есть пользователи, но, скажем, для другой организации:
struct AnotherDatabase : IDatabase
{
    ...
};

Мы конечно же хотим использовать наш UserManager, но уже с другой базой данных. Нет проблем:
UserManager manager;
manager.aDatabase = anSingle<AnotherDatabase>();
User user = manager.getUser(userId);

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

Литература


[0] Использование паттерна синглтон
[1] Википедия: синглтон
[2] Inside C++: синглтон
[3] Порождающие шаблоны: Одиночка (Singleton)
[4] Dependency inversion principle
[5] The Principles of OOD
[6] Wikipedia: Rvalue reference and move semantics
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+18
Comments 61
Comments Comments 61

Articles