вторник, 8 ноября 2011 г.

std::auto_ptr vs boost::scoped_ptr vs std::unique_ptr

Что такое smart pointer я думаю известно, на всякий случай о них можно прочитать в Википедии. Существуют различные виды умных указателей, и каждый из них имеет свою область применения, но одно у них общее - они обеспечивают управление (в том или ином виде) сроком жизни сырого (raw) указателя.

В данном примере мы рассмотрим три вида smart pointer. Все они предоставляют практические одинаковые возможности по управлению raw pointer. Также мне бы хотелось разобраться почему в новом стандарте C++11 auto_ptr объявлен устаревшим, что нужно использовать вместо него, и какие возможности есть в компиляторах не поддерживающих новый стандарт.

Основная проблема auto_ptr заключается в том, что он позволяет применить оператор delete к неполному (incomplete) типу, но если оператор delete применяется к неполному типу, то в этом случае не вызывается деструктор этого типа.
Реализация деструктора auto_ptr в stdlibc++ от gcc-4.4.5 тривиальна: ~auto_ptr() { delete _M_ptr; }
То есть данная реализация подвержена этой проблеме. Приведу небольшой пример, подтверждающий это:
#ifndef SIMPLECLASS_H #define SIMPLECLASS_H class SimpleClass { public: SimpleClass(); ~SimpleClass(); }; #endif // SIMPLECLASS_H
_Winnie C++ Colorizer

Реализация не менее тривиальна:
#include "SimpleClass.h" #include <iostream> SimpleClass::SimpleClass() { std::cout << "SimpleClass::ctor" << std::endl; } SimpleClass::~SimpleClass() { std::cout << "SimpleClass::dtor" << std::endl; }
_Winnie C++ Colorizer

Для наглядности я вывожу сообщение о вызове конструктора и деструктора.
Теперь создадим класс, который содержит в себе умный указатель на SimpleClass: идиома PImpl:
#ifndef CLASSOWNER_H #define CLASSOWNER_H #include <memory> class ClassOwner { public: ClassOwner(); private: std::auto_ptr<class SimpleClass> mImpl; }; #endif // CLASSOWNER_H
_Winnie C++ Colorizer

Шаблон auto_ptr позволяет декларировать объект неполного типа, потому-что в себе он содержит только указатель:
template<typename _Tp> class auto_ptr { private: _Tp* _M_ptr;
_Winnie C++ Colorizer

Реализация ClassOwner тоже тривиальна:
#include "ClassOwner.h" #include "SimpleClass.h" ClassOwner::ClassOwner(): mImpl(new SimpleClass) { }
_Winnie C++ Colorizer

Теперь сама функция main:
#include "ClassOwner.h" int main() { ClassOwner o; return 0; }
_Winnie C++ Colorizer

После сборки получаем такой вывод:

SimpleClass::ctor


А это немного не то, что мы ожидали увидеть.
Каким образом эту проблему можно обойти - добавить деструктор к классу ClassOwner:
#ifndef CLASSOWNER_H #define CLASSOWNER_H #include <memory> class ClassOwner { public: ClassOwner(); ~ClassOwner(); private: std::auto_ptr<class SimpleClass> mImpl; }; #endif // CLASSOWNER_H
_Winnie C++ Colorizer

#include "ClassOwner.h" #include "SimpleClass.h" ClassOwner::ClassOwner(): mImpl(new SimpleClass) { } ClassOwner::~ClassOwner() { /// needed only for correct destruction of auto_ptr }
_Winnie C++ Colorizer

После этого незначительного изменения программа работает как надо:

SimpleClass::ctor
SimpleClass::dtor

Я думаю не стоит объяснять что данная проблема может приводить к утечкам памяти (ресурсов), странному поведению программы и пр.

Вариантов решения данной проблемы несколько:
  • Не использовать auto_ptr - самый радикальный вариант, ниже я приведу примеры полноценной замены. Понятно что данный вариант может быть недоступен, если вы не используете по каким-то причинам сторонние библиотеки (Boost) или у ваша версия компилятора не поддерживает C++11.
  • Заставить компилятор выдавать warning или error (однако, для gcc я не смог найти такой ключ, хотя пробовал ключи -Wall -Wextra -pedantic -Weffc++).
  • Использовать статические анализаторы кода (PVS studio и пр.).
  • Провести code-review на предмет использования auto_ptr

Замена std::auto_ptr на std::unique_ptr
Чтобы использовать данную возможность необходим компилятор с поддержкой стандарта C++11 (бывший C++0x). Я пробовал компилятор g++-4.4 из Debian squeeze. Он имеет частичную поддержку нового стандарта и шаблон unique_ptr в нем уже есть.
Итак, меняем в нашем классе auto_ptr на unique_ptr и пытаемся скомпилировать (добавляем флаг -std=c++0x):
#ifndef CLASSOWNER_H #define CLASSOWNER_H #include <memory> class ClassOwner { public: ClassOwner(); //~ClassOwner(); private: /// @note compiled with -std=c++0x std::unique_ptr<class SimpleClass> mImpl; }; #endif // CLASSOWNER_H
_Winnie C++ Colorizer

Получаем сообщение об ошибке:

usr/include/c++/4.4/bits/unique_ptr.h:64: ошибка: некорректное применение ‘sizeof’ к неполному типу ‘SimpleClass’
/usr/include/c++/4.4/bits/unique_ptr.h:62: ошибка: static assertion failed: "can\'t delete pointer to incomplete type"
Итак, что же произошло: компилятор не смог вычислить sizeof от неполного типа, о чем нам и сообщил. Во второй строке мы видим более понятное сообщение, о том что не можем применить delete к указателю на неполный тип, это как раз то что нам нужно.
Каким образом он этого добился? Посмотрим на декларацию шаблона unique_ptr:
template <typename _Tp, typename _Tp_Deleter = default_delete<_Tp> > class unique_ptr
_Winnie C++ Colorizer

Мы видим, что данный шаблонный класс принимает два параметра: первый - тип данных, который мы оборачиваем, и второй параметр - функтор, который вызовется при выполнении операции reset (в деструкторе, или явный вызов), по умолчанию ставится функтор default_delete, вот его реализация:
/// Primary template, default_delete. template<typename _Tp> struct default_delete { default_delete() { } template<typename _Up> default_delete(const default_delete<_Up>&) { } void operator()(_Tp* __ptr) const { static_assert(sizeof(_Tp)>0, "can't delete pointer to incomplete type"); delete __ptr; } };
_Winnie C++ Colorizer
Мы видим, что в операторе () делается проверка времени компиляции (static_assert), в котором как раз проверяется, что sizeof от типа положителен, и если это утверждение неверно, то выдается ошибка компиляции с заданным сообщением.

Если мы раскоментируем декларацию и иплементацию деструктора ClassOwner, то компиляция пройдет успешно, и после выполнения программы мы увидим вызов конструктора и деструктора как и ожидали.

На самом деле класс unique_ptr предоставляет более гибкий механизм RAII по сравнению с auto_ptr, как раз благодаря второму параметру в шаблоне, ниже я приведу простой пример как его можно использовать:
#include <memory> #include <cstdio> #include <iostream> struct stdio_deleter { void operator ()(FILE * handle) { if (handle) { fclose(handle); std::cout << "closed" << std::endl; } } }; void workWithStdioBasedFiles() { typedef std::unique_ptr<FILE, stdio_deleter> file_ptr; file_ptr handle(fopen("file.txt", "w")); /** * work with file, we also may throw exception */ }
_Winnie C++ Colorizer
В данной функции мы получаем ресурс FILE - сишный способ работы с файлами, и сразу задаем deleter для данного ресурса - в данном случае это закрыть файл. При данном подходе мы получаем все прелести RAII без написания сложных оберток вокруг защищаемых ресурсов.

Замена std::auto_ptr на boost::scoped_ptr
Библиотека Boost предоставляет огромное количество компонент для ускорения разработки на C++, о ней можно долго говорить, но сейчас мы будем рассматривать только ее scoped_ptr.
Подключаем Boost к проекту (я использую CMake):
find_package(Boost REQUIRED)
include_directories(${Boost_INCLUDE_DIR})
Убираем ключ -std=c++0x, меняем реализацию класса:
#ifndef CLASSOWNER_H #define CLASSOWNER_H #include <boost/smart_ptr/scoped_ptr.hpp> class ClassOwner { public: ClassOwner(); //~ClassOwner(); private: boost::scoped_ptr<class SimpleClass> mImpl; }; #endif // CLASSOWNER_H
_Winnie C++ Colorizer

Пытаемся скомпилировать:

/usr/include/boost/checked_delete.hpp:32: ошибка: некорректное применение ‘sizeof’ к неполному типу ‘SimpleClass’
/usr/include/boost/checked_delete.hpp:32: ошибка: creating array with negative size (‘-0x00000000000000001’)

Как мы видим, здесь похожая ситуация: мы опять используем sizeof для того чтобы сформировать ошибку, но основное отличие в том, что мы пытаемся определить новый тип: массив.
Итак, смотрим деструктор scoped_ptr:
template<class T> class scoped_ptr // noncopyable { private: T * px; /// ... ~scoped_ptr() // never throws { #if defined(BOOST_SP_ENABLE_DEBUG_HOOKS) boost::sp_scalar_destructor_hook( px ); #endif boost::checked_delete( px ); }
_Winnie C++ Colorizer
Мы видим, что вызывается функция checked_delete, вот ее реализация:
// verify that types are complete for increased safety template<class T> inline void checked_delete(T * x) { // intentionally complex - simplification causes regressions typedef char type_must_be_complete[ sizeof(T)? 1: -1 ]; (void) sizeof(type_must_be_complete); delete x; }
_Winnie C++ Colorizer
Итак, что здесь происходит:
В первой строке мы пытаемся объявить тип type_must_be_complete - это массив. Если sizeof(T) != 0, то массив будет длины 1, в противном случае -1. Здесь используется две особенности языка C++:

  • sizeof от неполного типа приводит к ошибке компиляции
  • Нельзя объявить массив отрицательной длины

Две указанные особенности используются для того чтобы убедиться, что тип T полностью определен в момент инстанцирования шаблонной функции checked_delete, то есть при вызове деструктора scoped_ptr.

Итог:
Проблема auto_ptr стала понятна, но от старого кода (компилятора) никуда не деться, поэтому в уже существующих проектах можно просмотреть все места использования auto_ptr и проверить, что деструктор правильно вызовется. Также рекомендуется использовать уже готовые компоненты, типа Boost, Qt (есть класс QScopedPointer, который похож на std::unique_ptr), наконец, перейти на использование C++11, благо, что сейчас современные компиляторы уже имеют подержку нового стандарта. Также стал понятен механизм проверки, что тип является полностью определенным (complete type). Вкратце, макрос BOOST_STATIC_ASSERT аналогичным образом (но чуть-чуть сложнее :)) предоставляет механизм проверки утверждений на этапе компиляции.






9 комментариев:

  1. Что-то я сколько ни пробовал заставить "не вызваться" деструктор auto_ptr'а из первого примера - ничего не получается. Да собственно, я и понять не могу, как такое может выйти - у класса ClassOwner генерится деструктор по-умолчанию. Иначе и быть не может. Соотв. дёргается деструктор auto_ptr и т.д.

    Проблема auto_ptr в другом - у него копирующий конструктор совсем не копирующий: сигнатура auto_ptr(auto_ptr& x). Это значит, что он изменяет экземпляр, из которого копирует. Т.е. это и не копирование совсем. А вот uniq_ptr решает эту проблему и кучку других.

    ОтветитьУдалить
    Ответы
    1. Да, вы безусловно правы, что у ClassOwner генерируется деструктор по умолчанию. Но речь идет не про ClassOwner, а про SimpleClass, у которого определен пользовательский деструктор (в котором мы выводим сообщение на консоль). Здесь особенность заключается в том, что C++ (да и C) позволяет работать с неполными (incomplete) типами. И в случае C++ при удалении указателя на неполный тип компилятор не вызовет для него деструктор, так как он, грубо говоря, не знает где его искать: у компилятора есть только имя типа, но нет никакой информации о том как уничтожить объект. Ведь эта информация может даже не быть в полученном бинарнике (пример - сборка статической библиотеки).

      Что касается примера, то он полностью верен, я его проверил на g++-4.4.5. также выложил полный пример: github.com/prograholic/blog/tree/master/auto_ptr_tests

      Удалить
    2. Да, вы правы, я ошибся с примером. :)
      Но всё-таки главное отличие uniq_ptr от auto_ptr, то, что он не копируемый, а _перемещаемый_. Т.е. вы не можете сделать вот так:

      std::uniq_ptr p(new int);
      std::uniq_ptr p2 = p;

      а можете только:

      std::unique_ptr p(new int);
      std::unique_ptr p2 = std::move(p);

      Это значит, что умный указатель p может передаваться, как rvalue без проблем.
      И ещё unique_ptr умеет корректно удалять массивы объектов, т.к. использует оператор delete[], а не просто delete.

      Удалить
  2. Мне кажется товарисч Аноним не допонял всей эпичности данной ситуации

    struct X;

    func(X *px)
    {
    delete px; // компилятор проглотит, ошибки не будет, и деструктор не будет вызван, даже если он есть он где то есть
    }

    ОтветитьУдалить
  3. Забыты и не указаны главные вещи для программера:
    1) Алгоритмические сложности популярных операций с умными указателями
    2) Насколько увеличится размер исполняемого файла(в Байтах)

    ОтветитьУдалить
    Ответы
    1. 1 - А какие тут могут быть алгоритмические сложности? вызов inline функции, плюс опционально разыменование указателя. В теории грамотный компилятор все это уберет.

      2 - Абсолютно не главная вещь, только если вы не embedded программист.

      Удалить
    2. >>2 - Абсолютно не главная вещь, только если вы не embedded программист.
      Мы с Вами живем в эпоху глобального погружения йафоны\йапады и др. девайсы! ;) Так что надо знать!

      >>вызов inline функции, плюс опционально разыменование указателя.
      А при чему тут inline? Вы осознаете термин "алгоритмическая сложность" ? Любой алгоритм имеет сложность и совсем не важно будет ли он вставлен по месту вызова или нет.

      Удалить
    3. O(1), к гадалке не ходи

      Удалить