Пользователь
0,0
рейтинг
19 марта 2012 в 09:13

Разработка → Smart pointers для начинающих из песочницы

Эта небольшая статья в первую очередь предназначена для начинающих 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».
@flamingo
карма
13,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (44)

  • +6
    Почему-то не привели аналог boost:scoped_ptr<> из С++11 — std::unique_ptr<>.
    • 0
      std::unique_ptr<> — это аналог не boost:scoped_ptr<>, а std::auto_ptr<>

      Его точно так же можно возвращать из функции (но его также можно помещать в контейнер STL, в отличии от предшественника). А с boost:scoped_ptr<> нельзя ничего этого делать, поскольку он несколько обделен конструкторами.
  • +8
    В С++11 (как и библиотеке Boost) существует ф-ция make_shared, используя которую совместно с ключевым словом auto, проблему boiler-plate можно решить так:
    auto ptr = std::make_shared<MyNamespace::Object>(param1, param2, param3);
    
    • +2
      Открою секрет, использование это функции по двум причинам лучше, чем вызов конструктора.
      1) Не нужно 2 раза повторять имя класса ::std::shared_ptr(new T(...)) — короче
      2) 1 аллокация памяти вместо двух. В случае ::std::shared_ptr(new T()), сначала вы вызываете аллокацию (new T), а затем shared_ptr для счетчика ссылок. В случае make_shared, вызывается одна аллокацию большого куска, в которую потом размещающим конструктором будет положен ваш объект. Так что этот способ еще и эффективнее.
      • 0
        Парсер съел аргументы шаблона shared_ptr. В певром случае T повторяется 2 раза.
  • –11
    а насколько это удобнее чем ручное выделение и освобождение?
    • НЛО прилетело и опубликовало эту надпись здесь
      • –1
        Может быть я какойто мутант с гипертрофированой кратковременной памятью, но у меня никогда не возникало проблем с тем чтоб прибрать за собой, а вот автоматические гарбачколлекторы всегда мне создавали неудобства.
        • +1
          Наверное, вы никогда не писали код с иключениями. Или, если быть более строгим, вы никогда не писали код «безопасный относительно исключений». Там без RAII почти никак, если не впихивать try-catch куда только можно описанным ниже bogolt-ом способом
      • 0
        Обычно после подобной пары фраз от разных людей начинается Holy War.
    • +4
      Представьте себе что вы вызываете функцию и каждый раз должны думать не кидает ли она откуда-то внутри исключение? Если кидает — значит его нужно поймать, и внутри вызывать освобождение ресурсов. Но не всегда можно определить будет ли выброшено исключение или нет. К примеру у вас библиотека, вызывающая пользовательские коллбэки ( например виртуальные фукнции ) и вы в своем коде вообще не имеете представления что реально будет вызвано. Да вы можете каждый раз в таких случаях делать
      catch(...)
      {
      freeMyMemory();
      throw;
      }

      но чем больше лишнего кода — тем выше вероятность ошибиться.

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

      Я уже молчу про то что они могут помочь при многопоточности, и еще раз гарантировать вам что объекты проживут ровно столько сколько они нужны.
    • +1
      Со временем на столько привыкаешь, что без них уже как-то не в кайф :)
    • +1
      в два с половиной раза
  • +1
    Еще забыли упоминуть, не только о том что могут быть выброшены исключения во время работы с выделенными ресурсами, и тогда такой код:
    int *myPixels = new int[640 * 480];
    // работаем
    delete [] pixels;

    Так же может генерировать утечки памяти, а вы написали — В принципе, никакой разницы.
  • +5
    Было бы хорошо добавить по unique_ptr и про weak_ptr.
    • +2
      Да, я думал об этом, но статья и так получилась объемной. Попробую в следующей.
      • 0
        Ещё intrusive_ptr.
  • +1
    Мне кажется, что в вводной части статьи также необходимо хотябы дать ссылку на правило трех, а так — спасибо!
  • +4
    Статья неполна без объяснений почему лучше использовать make_shared и без приёмов работы shared_ptr.
    • 0
      make_shared актуально только в рамках C++11. В статье рассматривается C++03, я так понимаю.
      А вот про что, имхо, следовало написать дополнительно, так это про то, что копирование shared_ptr может быть дорогим (QSharedPointer, std::shared_ptr) по этому не следует передавать его в функции по значению, за исключением случаев, когда это действительно надо.
      • +2
        boost::make_shared актуален и для c++
        • –1
          Афаик, boost::make_shared не делает хитрого финта ушами при использование make_shared — shared_ptr занимает одинаково памяти, как при использование make_shared, так и при использование конструктора.

          По крайней мере на Going Native STL говорил, что уменьшение размера shared_ptr это MS-овское ноу-хау, которому еще предстоит переползти в реализации других вендорав shared_ptr.
          • 0
            Не из-за этого нужно использовать make_shared, не могу найти обсуждение этого, но вот 2 варианта кода:
            void func(shared_ptr<A> a1, shared_ptr<A> a2);
            func(shared_ptr<A>(new A()), shared_ptr<A>(new A()));

            и
            void func(shared_ptr<A> a1, shared_ptr<A> a2);
            func(make_shared<A>(), make_shared<A>());
            

            Первый вариант может привести к утечке памяти, второй правильный.
            • +1
              Да, вы чертовски правы. Применительно к auto_ptr об этом писал Саттер.
              Касательно ноу-хау STL-я (человека) — ну так думаю этот патч в буст добавят очень быстро. Ибо boost::shared_ptr в ближайшие пару лет будет распространен значииииительно сильнее, чем std::shared_ptr в виду того, что первый поддерживается почти всеми компиляторами и кода с ним уже навалом, а последний только самыми свежими с поддержкой C++11 (или хотя бы TR1).
      • 0
        почему копирование дорогое? потому что надо счётчик при вызове addref защитить от других потоков?
  • +10
    Ох, а зачем такие расстояния между строками в листингах? Тяжко читается.
    • 0
      судя по всему, это из-за
      white-space: pre-wrap;
      в стилях Хабра.
  • –1
    >При первом вызове delete все отлично, при втором вы потрете не те данные.

    Мне кажется там не стирается ничего.
    • +2
      Безопасно повторно удалять нулевой указатель, а ненулевой, указывающий на удаленный объект, — UB
    • +1
      Дабл делет это уб. Там может стираться, например, все содержимое Вашего жесткого диска.
      • +2
        Undefined Behavour не значит что комп может вдруг начать душить пользователя шнуром от мышки или вдруг стереть данные с диска. Иначе все эти «Программа совершила недопустимую операцию и будет закрыта» уже давно не оставили бы и следа от ваших данных.

        Происходит на самом деле следующее. Указатель хранит адрес памяти. При вызове делит он должен его освободить, отдать обратно в систему. В случае если предыдущий вызов уже освободил этот адрес, то он уже отдан системе, а значит при повторной попытке освободить ресурс не принадлежащий вашей программе и происходит эта сама ошибка «данные не могут быть READ/WRITE» или что там еще.

        Но бывает и иначе. К примеру память была освобождена, однако впоследствии вашей программе потребовалось еще немного памяти, система снова вам дала ее, и так совпало что та память которую вам вернула система — была та сама недавно удаленная. Событие не столь редкое как может показаться.
        И тогда при двойном удалении — у нас возникает совершенно фееричная ситуация когда программа пытается удалить удаленную память, которая на самом деле заново выделенная и принадлежит совсем другому участку этой же программы. Вот тут начинается полный undefined bahavour.
        К слову тоже самое может произойти не только с памятью но и с другим типом ресурсов — ведь почти все ресурсы внутри обозначаются как обычное целое число.
        Представьте что вы работали с сокетом под номером 0xdf82, затем закрыли соединение, потом к вам пришло новое соединение, и вы начали работать с новым сокетом. Но то то что он новый не отменяет того факта что его идентификатор вполне может совпадать со старым, уже давно освобожденным значением.
        Если же в какой-то переменной сохранилось старое значение, и где-то дальше по коду мы попробуем еще раз закрыть уже закрытое соединение ( чтобы наверняка, а то мало ли, кто эти компы знает ) то внезапно мы получим закрытие нашего нового соединения.
        • +5
          В случае с Вашей уютненькой вендой и Вашим уютненьким МС компилятором Вы корректно описали ситуацию, к которой приводит дабл делет. Но это не значит, что булгеон С++ компайлер из булгеон компайлер коллектион под БулгеонОС не отформатирует Ваш жесткий диск по дабл делет.

          Конечно, дабл делет не приведет к форматированию жесткого диска в случае, когда вы пользуетесь распространенным C++ компилятором под распространенной ОС.
        • +1
          > Undefined Behavour не значит что комп может вдруг начать душить пользователя шнуром от мышки или вдруг стереть данные с диска.

          Глупости. Все знают, что при UB ваша программа отсылает боссу письмо с ругательствами.
    • +1
      Если этот указатель (по которому делаем delete) нулевой, то да.
      Если не нулевой — память будет помечена свободной. И кто знает, что туда будет записано при следующем такте процессора.
    • 0
      Под стиранием я имел в виду целенаправленное обнуление памяти или запись некого другого значения и собственно хотел это проянить. Судя потому что ответили ниже такого обычно не происходит.
  • +2
    В картинках: http://boostnotes.blogspot.com/.
  • 0
    Статья понравилась, легко читается. Никогда не писал на С++, но всё было понятно :)
    Правильно ли я понимаю, что это мини garbage collector для С++?
    • 0
      Это управляемый garbage collector. В том смысле что вы явно указываете какая память и как будет освобождаться, и при неправильном использовании все равно можете получить ошибки связанные с памятью, например если создать на основании одной памяти два независимых умных указателя — они удаляют ее по очереди так как не будут знать друг про друга.
      • +2
        Это не является garbage collector, это совершенно другая технология (можно даже сказать, противоположная технология)
      • 0
        Это совсем не GC. Reference counting — один из подходов к решению задачи автоматического управления памятью. GC — другой подход.

    • 0
      больше похоже на automatic reference counting
  • 0
    Стоило бы отметить, что С++ позволяет создавать объекты в стеке, и тогда деструктор тоже вызывается автоматически, при выходе из scope, в котором создан объект.
  • 0
    А будет ли продолжение?
    Интересует применение shared_ptr для массивов и наличие чего-то подобного для пары malloc/free.
  • +1
    Обновите, пожалуйста, пост, разметка кода пострадала (

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.