Pull to refresh

Smart pointers для начинающих

Reading time 6 min
Views 200K
Эта небольшая статья в первую очередь предназначена для начинающих C++ программистов, которые либо слышали об умных указателях, но боялись их применять, либо они устали следить за new-delete.

UPD: Статья писалась, когда C++11 еще не был так популярен.

Итак, программисты С++ знают, что память нужно освобождать. Желательно всегда. И они знают, что если где-то пишется new, обязательно должен быть соответствующий delete. Но ручные манипуляции с памятью могут быть чреваты, например, следующими ошибками:
  • утечки памяти;
  • разыменовывание нулевого указателя, либо обращение к неициализированной области памяти;
  • удаление уже удаленного объекта;

Утечка в принципе не так критична, если программа не работает 24/7, либо код, ее вызывающий, не находится в цикле. При разыменовывании нулевого указателя мы гарантированно получим сегфолт, осталось только найти тот случай когда он становится нулевым (вы же понимаете о чем я). При повторном удалении может случиться вообще все, что угодно. Обычно (хотя может быть и не всегда), если вам выделяется блок памяти, то где-то с ним по соседству лежит значение, определяющее количество выделенной памяти. Детали зависят от реализациии, но допустим, что вы выделили 1 кб памяти начиная с некоторого адреса. Тогда по предыдущему адресу будет храниться число 1024, таким образом станет возможным при вызове delete удалить ровно 1024 байт памяти, не больше и не меньше. При первом вызове delete все отлично, при втором вы потрете не те данные. Чтобы всего этого избежать, либо уменьшить вероятность появления подобного рода ошибок, и были придуманы умные указатели.

Введение


Существует техника управления ресурсами посредством локальных объектов, называемая RAII. То есть, при получении какого-либо ресурса, его инициализируют в конструкторе, а, поработав с ним в функции, корректно освобождают в деструкторе. Ресурсом может быть что угодно, к примеру файл, сетевое соединение, а в нашем случае блок памяти. Вот простейший пример:
class VideoBuffer {
    int *myPixels;
public:
    VideoBuffer() {
        myPixels = new int[640 * 480];
    }
    void makeFrame() { /* Работаем с растром */ }
    ~VideoBuffer() {
        delete [] myPixels;
    }
};
int game() {
    VideoBuffer screen;
    screen.makeFrame();
}

Это удобно: по выходу из функции нам не нужно заботиться об освобождении буфера, так как для объекта screen вызовется деструктор, который в свою очередь освободит инкапсулированный в себе массив пикселей. Конечно, можно написать и так:
int game() {
    int *myPixels = new int[640 * 480];
    // Работаем
    delete [] myPixels;
}

В принципе, никакой разницы, но представим себе такой код:
int game() {
    int *myPixels = new int[640 * 480];
    // Работаем
    if (someCondition)
        return 1;        
    // Работаем дальше
    if (someCondition)
        return 4;        
    // Поработали
    delete [] myPixels;
}

Придется в каждой ветке выхода из функции писать delete [], либо вызывать какие-либо дополнительные функции деинициализации. А если выделений памяти много, либо они происходят в разных частях функции? Уследить за всем этим будет все сложнее и сложнее. Подобная ситуация возникает, если мы в середине функции бросаем исключение: гарантируется, что объекты на стеке будут уничтожены, но с кучей проблема остается открытой.
Ок, будем использовать RAII, в конструкторах инициализировать память, в деструкторе освобождать. И пусть поля нашего класса будут указателями на участки динамической памяти:
class Foo {
    int *data1;
    double *data2;
    char *data3;
public:
    Foo() {
        data1 = new int(5);
        ...
    }
    ~Foo() {
        delete data1;
        ...
    }
}

Теперь представьте, что полей не 3, а 30, а значит в деструкторе придется для всех них вызывать delete. А если мы второпях добавим новое поле, но забудем его убить в деструкторе, то последствия будут негативными. В итоге получается класс, нагруженный операциями выделения\освобождения памяти, да еще и непонятно, все ли было правильно удалено.
Поэтому предлагается использовать умные указатели: это объекты, которые хранят указатели на динамически аллоцированные участки памяти произвольного типа. Причем они автоматически очищают память по выходу из области видимости.
Сначала рассмотрим то, как они выглядят в С++, затем перейдем к обзору некоторых распространенных типов умных указателей.

Простейший smart pointer


// Класс нашего умного указателя
template <typename T>
class smart_pointer {
    T *m_obj;
public:
    // Отдаем ему во владение некий объект
    smart_pointer(T *obj)
        : m_obj(obj)
    { }
    // По выходу из области видимости этот объект мы уничтожим
    ~smart_pointer() {
        delete m_obj;
    }
    // Перегруженные операторы<
    // Селектор. Позволяет обращаться к данным типа T посредством "стрелочки"
    T* operator->() { return m_obj; }
    // С помощью такого оператора мы можем разыменовать указатель и получить ссылку на
    // объект, который он хранит
    T& operator* () { return *m_obj; }
}
int test {
    // Отдаем myClass во владение умному указателю
    smart_pointer<MyClass> pMyClass(new MyClass(/*params*/);    
    // Обращаемся к методу класса MyClass посредством селектора
    pMyClass->something();    
    // Допустим, что для нашего класса есть функция вывода его в ostream
    // Эта функция одним из параметров обычно получает ссылку на объект,
    // который должен быть выведен на экран
    std::cout << *pMyClass << std::endl;    
    // по выходу из скоупа объект MyClass будет удален
}

Понятно, что наш смарт пойнтер не лишен недостатков (например, как хранить в нем массив?), но он в полной мере реализует идиому RAII. Он ведет себя так же, как и обычный указатель (благодаря перегруженным операторам), причем нам не нужно заботиться об освобождении памяти: все будет сделано автоматически. По желанию к перегруженным операторам можно добавить const, гарантировав неизменность данных, на которые ссылается указатель.
Теперь, если вы поняли, что получаете определенные преимущества, при использовании таких указателей, рассмотрим их конкретные реализации. Если вам не нравится эта идея, то все равно, попробуйте использовать их в какой-нибудь своей маленькой программке, уверяю, вам должно понравиться.
Итак, наши смарт-пойнтеры:
  • boost::scoped_ptr
  • std::auto_ptr
  • std::tr1::shared_ptr (он же std::shared_ptr в C++11, либо boost::shared_ptr из boost)


boost::scoped_ptr


Он находится в библиотеке буст.
Реализация простая и понятная, практически идентичная нашей, за несколькими исключениями, одно из них: этот пойнтер не может быть скопирован (то есть у него приватный конструктор копирования и оператор присваивания). Поясню на примере:
#include <boost/scoped_ptr.hpp>
int test() {
    boost::scoped_ptr<int> p1(new int(6));
    boost::scoped_ptr<int> p2(new int(1));    
    p1 = p2; // Нельзя!
}

Оно и понятно, если бы было разрешено присваивание, то и p1 и p2 будут указывать на одну и ту же область памяти. А по выходу из функции оба удалятся. Что будет? Никто не знает. Соответственно, этот пойнтер нельзя передавать и в функции.
Тогда зачем он нужен? Советую применять его как указатель-обертка для каких-либо данных, которые выделяются динамически в начале функции и удаляются в конце, чтобы избавить себя от головной боли по поводу корректной очистки ресурсов.
Подробное описание здесь.

std::auto_ptr


Чуть-чуть улучшенный вариант предыдущего, к тому же он есть в стандартной библиотеке (хотя в C++11 вроде как deprecated). У него есть оператор присваивания и конструктор-копировщик, но работают они несколько необычно.
Поясняю:
#include <memory>
int test() {
    std::auto_ptr<MyObject> p1(new MyObject);
    std::auto_ptr<MyObject> p2;    
    p2 = p1;
}

Теперь при присваивании в p2 будет лежать указатель на MyObject (который мы создавали для p1), а в p1 не будет ничего. То есть p1 теперь обнулен. Это так называемая семантика перемещения. Кстати, оператор копирования поступает таким же образом.
Зачем это нужно? Ну например у вас есть функция, которая должна создавать какой-то объект:
std::auto_ptr<MyObject> giveMeMyObject();

Это означает, что функция создает новый объект типа MyObject и отдает его вам в распоряжение. Понятней станет, если эта функция сама является членом класса (допустим Factory): вы уверены, что этот класс (Factory) не хранит в себе еще один указатель на новый объект. Объект ваш и указатель на него один.
В силу такой необычной семантики auto_ptr нельзя использовать в контейнерах STL. Но у нас есть shared_ptr.

std::shared_ptr (С++11)


Умный указатель с подсчетом ссылок. Что это значит. Это значит, что где-то есть некая переменная, которая хранит количество указателей, которые ссылаются на объект. Если эта переменная становится равной нулю, то объект уничтожается. Счетчик инкрементируется при каждом вызове либо оператора копирования либо оператора присваивания. Так же у shared_ptr есть оператор приведения к bool, что в итоге дает нам привычный синтаксис указателей, не заботясь об освобождении памяти.
#include <memory> // Либо <tr1/memory> для компиляторов, еще не поддерживающих C++11
#include <iostream>
int test() {
    std::shared_ptr<MyObject> p1(new MyObject);
    std::shared_ptr<MyObject> p2;    
    p2 = p1;    
    if (p2)
        std::cout << "Hello, world!\n";
}

Теперь и p2 и p1 указывают на один объект, а счетчик ссылок равен 2, По выходу из скоупа счетчик обнуляется, и объект уничтожается. Мы можем передавать этот указатель в функцию:
int test(std::shared_ptr<MyObject> p1) {
    // Делаем что-то
}

Заметьте, если вы передаете указатель по ссылке, то счетчик не будет увеличен. Вы должны быть уверены, что объект MyObject будет жив, пока будет выполняться функция test.

Итак, smart pointers это хорошо, но есть и минусы.
Во-первых это небольшой оверхед, но я думаю у вас найдется несколько тактов процессора ради такого удобства.
Во-вторых это boiler-plate, например
std::tr1::shared_ptr<MyNamespace::Object> ptr = std::tr1::shared_ptr<MyNamespace::Object>(new MyNamespace::Object(param1, param2, param3))

Это частично можно решить при помощи дефайнов, допустим:
#define sptr(T) std::tr1::shared_ptr<T>

Либо при помощи typedef.
В-третьих, существует проблема циклических ссылок. Рассматривать ее здесь не буду, чтобы не увеличивать статью. Так же остались нерассмотренными boost::weak_ptr, boost::intrusive_ptr и указатели для массивов.
Кстати, smart pointers достаточно хорошо описаны у Джеффа Элджера в книге «С++ for real programmers».
Tags:
Hubs:
+60
Comments 44
Comments Comments 44

Articles