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

Прежде чем начать рассмотрение нововведений, давайте вспомним,что было в распоряжении программиста в стандарте C++03 касательно указателей. А было там не густо: “голые” указатели и один “умный” – std::auto_ptr. Т.к. std::auto_ptr был изначально спроектирован специфично(мягко говоря), очень быстро пришло осознание того, что в C++ должны прийти настоящие умные указатели. Они были доступны в компиляторах, которые поддерживали дополнение стандарта TR1 и конечно же в библиотеке boost. С настоящего момента, умные указатели являются официальной частью STL и могут(я бы сказал должны) использоваться любым программистом C++, для повышения качества кода. Умные указатели не просто привносят удобство работы с памятью, но и совершенно меняют парадигму программирования на C++, ставя жирную точку в споре о возвращаемых значениях и исключениях. Давайте рассмотрим, что же они представляют из себя и начнем мы с самого простого класса:

std::unique_ptr

std::unique_ptr является доработанной версией устаревшего std::auto_ptr, точнее идеи, которая стояла за оным.

std::auto_ptr помечен как deprecated в стандарте, а это значит вы должны прекратить его использовать, если использовали когда-либо

Из самого имени класса ясно, что он предназначен для эксклюзивного хранения указателя, т.е. в этом он повторяет поведение своего предтечи. Различия в них, на первый взгляд не очень значительные, – весьма существенны на деле. Объект класса unique_ptr является перемещаемым, но не копируемым, что в терминах С++ означает, что у него определен оператор перемещения(operator=(T&&)) и конструктор перемещения, а оператор копирования и конструктор копирования отсутствуют. После перемещения(равно как и при создании посредством конструктора по умолчанию) unique_ptr содержит в себе nullptr. Подобное поведение делает его тем, чем не смог стать auto_ptr со своей “кривой” семантикой и неказистым именем:

#include <memory>
#include <cassert>

int main()
{
    std::unique_ptr<int> spPtr(new int);
    //auto spAnotherPtr = spPtr; Ошибка - копирование запрещено
    auto spAnotherPtr = std::move(spPtr);
    assert(std::unique_ptr<int>() == nullptr);
    assert(spPtr == std::unique_ptr<int>());
}

Еще одним отличием является возможность хранения указателя на массив объектов. Для этого при создании объекта unique_ptr необходимо использовать особый синтаксис: std::unique_ptr<T[]>. При создании unique_ptr, для хранения массива, теряется смысл в операторах “–>” и “.”, поэтому они исключены в соответствующей специализации шаблона. Зато добавлен оператор “[]”. Благодаря этой специализации мы получаем полноценный умный контейнер для хранения указателя на массив, с полным сохранением семантики массива; этакий аналог boost::scoped_array. Правда я не очень понимаю смысл сей специализации при ныне здравствующем std::vector; не иначе как лобби “оптимизаторов” Улыбка

#include <memory>
#include <iostream>

class A
{
public:
    ~A()
    {
        std::cout << "Dtor\n";
    }
};

int main()
{
    {
        std::unique_ptr<A> spPtr(new A());
        std::unique_ptr<A[]> spArray(new A[10]);
    }
}

Выполнив этот код вы увидите 11 последовательно выведенных слов “Dtor”

Продолжая тему операции удаления следует упомянуть еще одно важное отличие от auto_ptr: unique_ptr позволяет задать пользовательскую операцию удаления, что дает возможность работать не только с указателями, память под которые выделена с помощью new, но и с любыми другими.

#include <windows.h>
#include <memory>
#include <functional>

int main()
{
    HANDLE File = ::CreateFile(L"test.txt", 
        GENERIC_WRITE,
        FILE_SHARE_READ,
        NULL, CREATE_ALWAYS,
        FILE_ATTRIBUTE_NORMAL,
        NULL);
    {
        std::unique_ptr<void, decltype(std::ptr_fun(&::CloseHandle))> 
            spHandle(File, std::ptr_fun(&::CloseHandle));
    }
}

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

Резюме:

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

std::shared_ptr

Если unique_ptr стал преемником auto_ptr, то shared_ptr не имеет предтечи в прошлом стандарте. Полагаю, что читатель уже догадался из названия, что этот тип указателя, в противовес unique_ptr, является разделяемым. Это значит, что он может быть скопирован и каждая его копия будет обращаться к одному и тому же указателю. При разрушении объекта shared_ptr ресурс, хранящийся в нем, будет освобожден тогда и только тогда, когда не существует других объектов shared_ptr ссылающихся на этот ресурс. Или другими словами: указатель будет освобожден тогда, когда будет уничтожена последняя копия изначального shared_ptr, если хоть одна было создана. Это реализовано посредством счётчика ссылок.

Это, пожалуй, наиболее плотно используемый тип умного указателя, т.к. на фоне остальных указателей он выглядит наиболее мощным и легко используемым. Действительно, зачем заморачиваться с пониманием unique_ptr и прочего, когда есть shared_ptr? – удобный и эффективный метод организации RAII. Но мы, все же, разработчики C++ и не будем уподобляться тем, кто так считает. Это не верный подход.

Поясню свою точку зрения: shared_ptr обладает большими возможностями, но они не бесплатны, за все придется платить памятью и быстродействием. Также придется платить семантическим диссонансом, который неизбежен при использовании shared_ptr там, где должен быть использован unique_ptr.

Кстати, кое в чем unique_ptr превосходит shared_ptr функционально, а именно: shared_ptr не позволяет, “из коробки”,  хранить в себе указатели на массивы, - сомнительное преимущество, но все же…

#include <memory>
#include <cassert>

int main()
{
    std::shared_ptr<int> spPtr(new int);
    assert(spPtr.unique());
    {
        auto spAnotherPtr = spPtr;
        assert(spPtr == spAnotherPtr);
        assert(!spPtr.unique());
        assert(spPtr.use_count() == 2);
    }
    assert(spPtr.unique());
}

Перейдем к возможности задания пользовательской операции удаления в shared_ptr. Сначала приведу пример, а затем обсудим:

#include <windows.h>
#include <memory>

int main()
{
    HANDLE File = ::CreateFile(L"test.txt", 
        GENERIC_WRITE,
        FILE_SHARE_READ,
        NULL, CREATE_ALWAYS,
        FILE_ATTRIBUTE_NORMAL,
        NULL);
    {
        std::shared_ptr<void> spHandle(File, &::CloseHandle);
    }
}

Для наглядности я использовал тот же пример, что и для unique_ptr. Не находите, что он выглядит несколько опрятнее? Улыбка Все это происходит благодаря еще одной особенности shared_ptr – этот шаблонный класс зависит только от типа указателя, т.е. тип операции удаления не влияет на инстанциацию шаблона! Это, также, означает, что shared_ptr созданный с одной операцией удаления может быть присвоен другому shared_ptr с другой операцией удаления. Также, все, что описано выше для пользовательской операции удаления справедливо и для пользовательского аллокатора. Это прекрасное свойство shared_ptr, которое делает его более удобным для использования с пользовательскими аллокаторами и операциями удаления. Почему точно также не реализован unique_ptr, спросите вы? Ответ прост – unique_ptr сделан максимально легким с минимальными накладными расходами, shared_ptr, же, сделан больше для удобства и это уже вам выбирать, чем жертвовать.

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

  • все те места, где unique_ptr не может быть использован
  • для копирования типов, у которых отсутствует операция копирования. К примеру, когда std::promise нужно передать в другой поток.
  • случаи, когда необходим разделяемый доступ к ресурсу

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

std::make_shared и std::allocate_shared

Обе функции служат одной цели – создание shared_ptr в недрах реализации. allocate_shared отличается лишь тем, что позволяет задать пользовательский аллокатор(сиречь операцию выделения памяти) при создании shared_ptr. Т.к. использование собственного аллокатора является более редким случаем, я буду рассказывать о плюсах на примере make_shared, но это будет в равной степени относиться и к allocate_shared.

#include <memory>
#include <cassert>

class A
{
    int m_i;
    int m_j;
public:
    A(int i, int j): m_i(i), m_j(j)
    {}
    bool operator==(const A& Rhs)
    {
        return m_i == Rhs.m_i && m_j == Rhs.m_j;
    }
};

int main()
{
    std::shared_ptr<A> spFirst(new A(1,2));
    auto spSecond = std::make_shared<A>(1,2);
    assert(*spFirst == *spSecond);
}

Из вышеприведенного кода видно, что код с make_shared позволяет не использовать в своей записи new и указывать тип указателя лишь один раз. Хотя для этого примера количество упоминаний типа не существенно в примере с длинными именами может дать неплохой выигрыш в длине строки, а, следовательно, и в её читабельности. Помимо чисто эстетического превосходства(в чем вы можете со мной не согласится) make_shared обладает объективным преимуществом, за счет сокрытия использования new в деталях реализации:

foo(std::shared_ptr<std::string>(new std::string("first")), 
        std::shared_ptr<std::string>(new std::string("second")));//#1
foo(std::make_shared<std::string>("first"), 
        std::make_shared<std::string>("second"));//#2

Строчка #1 является потенциальной утечкой памяти, тогда как строчка #2 является полностью безопасной.

Помимо элегантности и стойкости перед лицом исключений, make_shared может быть более эффективным как в потреблении памяти, так и в быстродействии. Для лучшего понимания я приведу 2 картинки:

 

shared

Простое создание, посредством new

 

 

make_shared

Посредством make_shared

При создании объекта shared_ptr “классическим” методом. требуется 2 шага: выделить память с помощью некоторого аллокатора и создать объект shared_ptr, который получит выделенную память в свою зону ответственности. Для такого создания всегда требуется два выделения памяти: под данные(например, пользователь выделяет посредством new) и под блок счётчика ссылок(всегда выделяется без участия пользователя). Таким образом при создании shared_ptr “классическим” методом происходит два выделения памяти в разных областях кучи, а при уничтожении последнего объекта shared_ptr, ссылающегося на данный блок данных, происходит два освобождения занятой памяти.

С другой стороны, при использовании make_shared, блок счетчика ссылок и данные можно расположить в памяти последовательно, в одном выделенном блоке памяти. Это достигается за счёт того, что создание объекта и выделение памяти под него изолируется от пользователя и происходит внутри функции make_shared.

Не берусь утверждать, что так реализовано в каждом компиляторе, но есть все шансы, что это так. В MSVC 2010 это именно так.

За счёт подобного размещения при создании объекта происходит одно выделение памяти, и одно освобождение памяти происходит при разрушении последнего ссылающегося на этот блок объекта shared_ptr. Не бог весть что, но, все же, лучше чем ничего, а вкупе с остальными плюсами make_shared является безусловным лидером, а следовательно и стандартом де факто при создании shared_ptr.

Правда, есть и один недостаток у make_shared: с помощью этой функции нельзя задать пользовательскую операцию удаления. П��этому нельзя покрыть все случаи создания shared_ptr при помощи функции make_shared.

std::enable_shared_from_this

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

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

  • Содержит массив указателей на потомков
  • Содержит указатель на родителя
  • Имеет метод для смены родителя, который удаляет себя из предыдущего родителя и добавляет себя к новому

Звучит все, вроде бы, просто и реализация будет тривиальной для “голых” указателей. Но мы же прониклись идеей умных указателей и не желаем использовать их более простых товарищей! Попробуем реализовать требуемый класс с помощью умных указателей:

#include <memory>
#include <cassert>
#include <vector>
#include <algorithm>
#include <iterator>

class Node
{
private:
    typedef std::shared_ptr<Node> Child_t;
    typedef std::vector<Child_t> Children_t;
private:
    Node* m_pParent;
    Children_t m_ChildList;
public:
    Node(): m_pParent(nullptr)
    {
    }
    void AddChild(const Child_t& Child)
    {
        m_ChildList.push_back(Child);
        Child->m_pParent = this;
    }
    void RemoveChild(const Child_t& Child)
    {
        auto NewEnd = std::remove(std::begin(m_ChildList), 
            std::end(m_ChildList), Child);
        m_ChildList.erase(NewEnd, std::end(m_ChildList));
    }
    void SetParent(Node* pNewParent)
    {
        m_pParent->RemoveChild(/*Что тут писатьjQuery15209131074224684714_1327721993545?*/);
        pNewParent->AddChild(/*Что тут писать???*/);
    }
    size_t ChildrenCount() const
    {
        return m_ChildList.size();
    }
    Node* Parent() const
    {
        return m_pParent;
    }
};

int main()
{
    auto spRoot = std::make_shared<Node>();
    auto spChild = std::make_shared<Node>();
    spRoot->AddChild(spChild);
    assert(spChild->Parent() == spRoot.get());
    assert(spRoot->ChildrenCount() == 1);
    auto spAnotherRoot = std::make_shared<Node>();
    spChild->SetParent(spAnotherRoot.get());
    assert(spRoot->ChildrenCount() == 0);
    assert(spAnotherRoot->ChildrenCount() == 1);
    assert(spChild->Parent() == spAnotherRoot.get());
}

Комментарии в коде говорят сами за себя: как же нам удалить и добавить потомка имея на руках только this, тогда как нам нужен объект shared_ptr? Наивный подход мог бы выглядеть следующим образом:

void SetParent(Node* pNewParent)
{
    std::shared_ptr<Node> thisPtr(this); 
    m_pParent->RemoveChild(thisPtr);//#1
    pNewParent->AddChild(thisPtr);//#2
}

На строке #2 программа может упасть. В нашем случае этого не произойдет, т.к. spChild еще жив в функции main. Но в более реальном примере this на данном этапе уже будет удален. Но даже в этом примере программа упадет, хотя и не в этом месте, т.к. мы имеем два не связанных shared_ptr указывающих на один блок данных.

Дабы решить эту проблемы enable_shared_from_this и был придуман. Наследование от этого класса позволяет получать shared_ptr из this. При этом, это всегда будет shared_ptr с одним и тем же блоком счётчика ссылок, а значит мы не получим коллизий, которые получили в “наивном” решении при повторном создании shared_ptr из указателя, который уже находился в зоне ответственности другого shared_ptr.

Внесем необходимые исправления:

class Node: public std::enable_shared_from_this<Node>

и

void SetParent(Node* pNewParent)
{
    m_pParent->RemoveChild(shared_from_this());
    pNewParent->AddChild(shared_from_this());
}

Задача решена.

К сожалению, в силу ограничений языка мы имеем следующие ограничения при использовании enable_shared_from_this:

  • Объекты классов, наследующих enable_shared_from_this и использующие метод shared_from_this, должны быть созданы как shared_ptr изначально.
  • Метод shared_from_this не может быть использован в конструкторе класса, наследующего enable_shared_from_this.

Второе ограничение не позволяет реализовать конструктор, который получает родителя в качестве аргумента, который, в свою очередь, добавляет this как потомка:

Node(Node* pParent): m_pParent(pParent)
{
    //Так делать нельзя!
    m_pParent->AddChild(shared_from_this());
}

dynamic_pointer_cast , static_pointer_cast и const_pointer_cast

Следующими в полку функций-помощников являются методы дублирующие функциоанал базовых операторов dynamic_cast, static_cast и const_cast применяемых к “голым” указателям.

Это методы-обертки для упрощения адаптации к shared_ptr; используйте их при необходимых преобразованиях вместо применения базовых операторов к результату shared_ptr::get() . Их применение тривиально:

std::static_pointer_cast<void>(spChild);

Результатом выражения будет shared_ptr<void> объект.

owner_before

Последним методом, который заслуживает отдельного упоминания, является метод owner_before. Этот метод является альтернативной версией оператора <. Оператор < выполняет сравнение базируясь на указателе на данные(first.get() < second.get()), тогда как owner_before выполняет компиляторо-зависимое сравнение концептуально эквивалентное оператору <. Есть даже специальный функтор std::owner_less, предназначенный как замена стандартному std::less в контейнерах set и map, внутри этого функтора выполняется проверка посредством owner_before. При этом соблюдается правило эквивалентности известное вам по оператору <, - два shared_ptr(first и second) эквивалентны если выполняется следующее условие:

!first.owner_before(second) && !second.owner_before(first)

Поскольку комитет стандартизации не регламентировал то, как эта проверка должна выполнятся, а отдал это на откуп разработчикам компиляторов, то и говорить о реализации нечего. Важно лишь понимать, что установленная эквивалентность при сравнении посредством owner_before может быть не установлена при использовании оператора <:

#include <memory>
#include <cassert>

class A
{
    int i;
};
class B
{
    int j;
};
class C: public A, public B
{};

struct Z
{
    B* m_B;
    Z(): m_B(new B)
    {}
};


int main()
{
    //#1
    {
    auto spInitial = std::make_shared<C>();
    std::shared_ptr<void> spDerived =
        std::static_pointer_cast<B>(spInitial);
    assert(spInitial != spDerived);
    assert(spInitial < spDerived || spInitial > spDerived);
    assert(!spInitial.owner_before(spDerived) && 
        !spDerived.owner_before(spInitial));
    }
    //#2
    auto spInitial = std::make_shared<Z>();
    std::shared_ptr<B> spInner(spInitial, spInitial->m_B);
    assert(!spInitial.owner_before(spInner) &&
        !spInner.owner_before(spInitial)); 
}

В части #1, можно наблюдать то, что я описал выше: при сравнении объектов оператором < объекты не являются эквивалентными, т.к. spDerived хранит указатель на B часть указателя C, и поэтому они не являются эквивалентными. Но при сравнении посредством owner_before эквивалентность устанавливается, т.к. по сути оба этих указателя указывают на один блок памяти, только в разные его части, т.е. они эквивалентны по владению. Для упрощения понимания этой концепции можно думать об этом как об эквивалентности блоков счётчиков ссылок, т.е. происходит сравнение не указателей на данные, а указателей на блок счётчика ссылок. Я не утверждаю, что именно так реализовано в вашем компиляторе, но этот способ вполне логичен.

В части #2 затронут интересный конструктор shared_ptr, который позволяет делится владением, не делясь данными. Т.е. блок счётчика ссылок становится общим, а данные могут быть разными. Вплоть до того, что вы можете создать shared_ptr c nullptr и блоком счётчика ссылок из другого shared_ptr.

В части #2 я привел еще более надуманный и высосанный из пальца пример необходимости применения owner_before, а необходимо это т.к. мы имеем два shared_ptr с разными типами данных и оператор < не может быть применен, в то время как owner_before отрабатывает на ура.

Прошу прощения, за несколько сумбурное изложение материала касательно owner_before, а также примеры применения оного. Дело в том, что я не понимаю зачем нужен этот метод и никакого вразумительного объяснения я не нашел. Но и пройти мимо него я не мог. Возможно он добавлен как забота о будущем, не знаю. Не вижу я в нем смысла вне рамок std::weak_ptr, но об этом будет дальше. Теперь вы представляете себе, что с помощью него можно сделать, а как его применять уже решать вам. Буду рад если вы мне сообщите реальные примеры его применения.

boost::intrusive_ptr

Также, в рамках разговора о shared_ptr, я бы хотел упомянуть и boost::intrusive_ptr. Это тоже умный указатель, но он не включен в стандарт C++11. Он схож с std::shared_ptr, в целом, но позволяет задать операцию инкремента и декремента счётчика ссылок самостоятельно. Операция декремента, также, ответственна за освобождения памяти выделенной под объект

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

Применение данного типа указателя рационально для типов данных, которым нужны пользовательские операции работы со счётчиками. Для всего остального есть shared_ptr.

 

Возможно, некоторые читатели воскликнули во время прочтения материала: “А что этот апологет умных указателей сделал “голым” указатель на род��теля в классе Node? Почему не shared_ptr?” Дело в том, что если указатель на родителя сделать объектом shared_ptr, тогда получится, что родитель(уже помещенный в shared_ptr) будет содержать потомка в котором содержится shared_ptr содержащий родителя. Но при таком варианте потомок никогда не будет удален, поскольку он сам содержит shared_ptr своего родителя! Эта проблема получила название проблемы перекрестных ссылок и вот еще её разновидности: объект содержит shared_ptr на себя же, или два разных объекта содержат shared_ptr друг на друга. В обоих случаях объекты не будут удалены никогда, т.к. из-за перекрестных ссылок, счетчик никогда не упадет до 0. Проблема перекрестных ссылок  легко решаются с применением std::weak_ptr.

На самом деле, в примере с Node даже std::weak_ptr избыточен, т.к. не может быть такой ситуации когда родитель уничтожен, а потомки здравствуют. Именно поэтому там применен “голый” указатель. В другом там просто нет смысла.

std::weak_ptr

Итак, мы знаем в каком случае стоит применять weak_ptr, но не знаем, что это такое. Давайте восполнять пробел: weak_ptr не является самостоятельным умным указателем, а лишь довеском к shared_ptr. Конструируется weak_ptr из существующего shared_ptr и его единственной целью является предоставление пользователю информации об ассоциированным с ним shared_ptr без увеличения счётчика ссылок оного. Из объекта weak_ptr можно получить следующую информацию:

  • Является ли ассоциированный shared_ptr “живым” или же счетчик ссылок обнулен и объект не существует более. Для этого в weak_ptr существует метод expitred()
  • Можно получить сам объект shared_ptr непосредственно, точнее его копию. Данная операция увеличит счётчик ссылок, осуществляется это вызовом метода lock()

Непосредственно обратиться к shared_ptr, на который ссылается weak_ptr нельзя. Поэтому при необходимости обращения к самому объекту, который хранится в shared_ptr необходимо использовать lock().

Не стоит злоупотреблять функцией lock(), оставляя полученный shared_ptr в течение долгого времени. Следует избавляться  от полученного объекта сразу, как в нем пропадает необходимость. Иначе сам принцип заложенный в weak_ptr , будет нарушен.

Таким образом, помимо решения проблемы циклических ссылок, weak_ptr  обладает приятным бонусом отслеживания времени жизни объекта на который он ссылается. Используйте weak_ptr везде, где он может заменить shared_ptr,- это уменьшает связность.

#include <memory>
#include <cassert>

int main()
{
    auto spShared = std::make_shared<int>();
    std::weak_ptr<int> Weak(spShared);
    assert(Weak.expired() == false);
    assert(Weak.use_count() == 1);
    auto spClone = Weak.lock();
    assert(Weak.use_count() == 2);
    spShared.reset();
    assert(Weak.use_count() == 1);
    spClone.reset();
    assert(Weak.expired());
}

Кстати, для класса weak_ptr, вышеупомянутый, owner_before обретает новые краски. Дело в том, что у weak_ptr отсутствует оператор <, а следовательно его нельзя сравнить никаким образом кроме как при помощи owner_before. Поэтому, для хранения weak_ptr в std::set и std::map необходимо использовать std::owner_less в качестве сравнивающего функтора. Кроме того, с помощью этого метода можно провести сравнение между shared_ptr и weak_ptr! Не знаю кому это может пригодится, но тем не менее.

 

Итог

Требующая предельного внимания модель работы с памятью в C++ отжила своё. Являясь современным программистом на C++ вы должны понимать, что модель построенная на RAII пришла ей на смену и сопротивление бесполезно и вредно. Нет смысла в использовании “голых” указателей, там где они должны быть заменены своими умными собратьями(обратных случаев не так уж много и связаны они, в основном, со старым кодом).

P.S. Все вышеописанные, указатели могут принимать неполный тип в качестве аргумента шаблона.