GC в C++: преодоление соблазна

в 3:08, , рубрики: c++, инкапсуляция, ооп, сборка мусора, указатели, управление памятью, метки: , , ,

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

Суть метода предельно проста: если каждый объект является переменной какой-либо области видимости или простым («стековым») членом другого объекта, то даже при аварийном закрытии программы от необработанного исключения, всегда будет происходить корректная очистка. Задача заключается в том, чтобы свести всё многообразие динамических сценариев к этой схеме.

1. Каждый объект имеет ровно одного владельца.

Самый главный принцип. Прежде всего, он означает, что в ходе выполнения программы объект может быть удалён только единственным объектом-владельцем, и никем другим.
В простейшем, «статическом», случае, это означает просто включение объекта в класс-владелец обычным образом в качестве члена. Я противопоставляю ему более экзотические варианты включения объекта в класс его владельца через указатель или ссылку (заметьте, не в любой класс, а в класс-владелец).
«Корневные» объекты программы объявляются «стековыми» переменными в main(). Причём, лучше всё-таки в main(), чем в виде глобальных переменных, потому что в первом случае можно гарантировать порядок очистки (противопоставляю случай с набором глобальных объектов, разбросанных по единицам трансляции).

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

class SomeParent
{
    Child1 child1;
    Child2 child2;
};

class Root
{
public:
    void Run();
private:
    SomeParent entry;
};

int main(int argc, char **argv, char **envp)
{
    Root().Run(); //даже при выбросе исключения, не будет утечек
}

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

2. Владеющие контейнеры.

Для хранения динамически создаваемого объекта применять контейнер с автозачисткой. Сам контейнер при этом объявляется обычным, «стековым» членом класса. Это может быть какой-то из вариантов умного указателя, либо ваша собственная реализация контейнера:

template <T> class One
{
public:
    One(); //изначально пустой
    One(T *); //владение объектом переходит контейнеру
    void Clear(); //уничтожение объекта вручную
    T *operator->(); //доступ к указателю
    T *GetPtr();
    ~One(); //автозачистка
};

//--- использование:
class Owner
{
    One<Entity> dynamicEntity;
};

В этом случае можно сказать, что контейнер является владельцем объекта.

3. Владеющие массивы.

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

template <T> class Array
{
public:
    T & Add();
    T & Add(const T &); //копирование
    T & Add(T *); //владение переходит массиву
    ~Array(); //уничтожает входящие элементы
};

//для ассоциативных массивов - аналогично:
template <K,T> class ArrayMap
{
public:
    T & Add(const K &);
    T & Add(const K &, const T &); //копирование
    T & Add(const K &, T *); //владение переходит массиву
    ~ArrayMap(); //уничтожает входящие элементы
};

//--- использование:
class Owner
{
    Array<String>        stringsArray;
    ArrayMap<int,String> anotherStringsCollection;
};

Понятно, что владельцем всей коллекции объектов является массив, являющийся обычным, «стековым» членом класса-владельца.
Из подобных владеющих примитивов можно создавать довольно сложные модели. Следующий уровень сложности — передача объектов между владельцами:

4. Передача объектов передаёт право владения.

У каждого объекта — ровно один владелец. Владелец может передать объект другому владельцу, но сам теряет доступ к объекту.
Передачу объекта вместе с правом владения можно сделать, добавив в массивы и контейнеры разрушающее копирование внутреннего указателя:

template <T> class One
{
public:
    //...
    One(const One<T> &source); //ptr = source.ptr; source.ptr = NULL;
    void operator=(const One<T> &source); //ptr = source.ptr; source.ptr = NULL;
    bool IsEmpty(); //узнать, владеем ли мы объектом

private:
    mutable T *ptr;
};

//аналогичный функционал добавляется и для массивов

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

Снова напоминаю, что это всё работает только в том случае, когда мы строго придерживаемся правила, что у любого объекта есть ровно один владелец.
А это значит, что даже если владелец передаёт «наружу» ссылку или указатель на объект, получатель может просить этот объект поучаствовать в каком-то функционале (путём вызова открытых функций-членов объекта). Но не может этот объект удалить, так как не является его владельцем:

class CleverEntity
{
public:
    void UpdateUI(Window *window)
    //получая указатель, получатель соглашается на использование объекта,
    //но не будет влиять на его жизненный цикл
    {
        //window->...
        //запрещено: delete window и прочие попытки уничтожить
       //            либо перехватить владение объектом
    }
};

class WindowWorker
{
public:
    void UpdateUI()
    {
        entity.UpdateUI(window.GetPtr());
    }
private:
    CleverEntity  entity;
    One<Window>   window;
};

На этом всё.
Может и не «серебряная пуля», но для абсолютного большинства применений, вполне достаточно.
Более сложные сценарии, при желании, можно будет разобрать в комментариях.

P.S. Заинтересовавшимся этой темой, рекомендую ознакомиться с библиотекой, где все эти концепции уже реализованы — пакетом Core (концепция, пример массива) фреймворка U++ (лицензия BSD). Там по-своему объясняется эта методика, а также некоторые другие интересные возможности (быстрая компиляция, быстрое разрушающее копирование, ускорение массивов на порядок).

Некоторые теоретические аспекты подхода были изложены в одной из предыдущих статей.

Автор: mt_

* - обязательные к заполнению поля