Использование "умных" указателей

Принципы использования "умных" указателей известны каждому программисту на C++. Идея предельно проста: вместо того, что бы пользоваться объектами некоторого класса, указателями на эти объекты или ссылками, определяется новый тип для которого переопределен селектор ->, что позволяет использовать объекты такого типа в качестве ссылок на реальные объекты. На всякий случай, приведу следующий пример:
class A {  
public: void method();
};
class APtr { 
protected: A* a;
public: APtr();
~APtr();
A* operator->();
};  
inline APtr::APtr() : a(new A) { }   
inline APtr::~APtr() { delete a; }  
inline A* APtr::operator->() { return a; }
Теперь для объекта, определенного как APtr aptr; можно использовать следующую форму доступа к члену a:

aptr->method();

Тонкости того, почему operator->() возвращает именно указатель A* (у которого есть свой селектор), а не, например, ссылку A& и все равно все компилируется таким образом, что выполнение доходит до метода A::method(), я пропущу за ненадобностью --- я не собираюсь рассказывать о том, как работает этот механизм и какие приемы применяются при его использовании --- это очень подробно написано в книге Джеффа Элджера "C++ for real programmers" (комментарий к этой замечательной книге обязательно появится в соответствующем разделе моей странички), в которой вообще буквально половина книги посвящена "умным" указателям.

Преимущества такого подхода, в принципе, очевидны: появляется возможность контроля за доступом к объектам; немного простых телодвижений и получается указатель, который сам считает количество используемых ссылок и при обнулении автоматически уничтожает свой объект, что позволяет не заботиться об этом самостоятельно... не важно? Почему же: самые трудно отлавливаемые ошибки это ошибки в использовании динамически выделенных объектов. Сплошь и рядом можно встретить: попытка использования указателя на удаленный объект, двойное удаление объекта по одному и тому же адресу, неудаление объекта. При этом последняя ошибка, в принципе, самая безобидная: программа, в которой не удаляются объекты (следовательно, теряется память, которая могла бы быть использована повторно) может вполне спокойно работать в течение некоторого времени (причем это время может спокойно колебаться от нескольких часов до нескольких дней), чего вполне хватает для решения некоторых задач. При этом заметить такую ошибку достаточно просто: достаточно наблюдать динамику использования памяти программой; кроме того, существуют специальные средства для отслеживания подобных казусов, например BoundsChecker.

Первая ошибка в этом списке тоже, в принципе, достаточно простая: использование после удаления скорее всего приведет к тому, что операционная система скажет соответствующее системное сообщение. Хуже становится тогда, когда подобного сообщения не появляется (т.е., данные достаточно правдоподобны или область памяти уже занята чем-либо другим), тогда программа может повести себя каким угодно образом.

Вторая ошибка может дать самое большое количество неприятностей. Все дело в том, что, хотя на первый взгляд она ничем особенным не отличается от первой, тем не менее на практике повторное удаление объекта приводит к тому, что менеджер кучи удаляет что-то совсем невообразимое. Вообще, что значит "удаляет"? Это значит, что помечает память как пустую (готовую к использованию). Обычно, менеджер кучи, для того что бы знать, сколько памяти удалить, в блок выделяемой памяти вставляет его размер. Так вот, если память уже была занята чем-то другим, то по "неверному" указателю находится неправильное значение размера блока, таким образом менеджер кучи удалит некоторый случайный размер используемой памяти. Это даст следующее: при следующих выделениях памяти (рано или поздно) менеджер кучи отдаст эту "неиспользуемую" память под другой запрос и... на одном клочке пространства будут ютиться два разных объекта. Крах программы произойдет почти обязательно, это лучшее что может случится. Значительно хуже, если программа останется работать и будет выдавать правдоподобные результаты. Одна из самых оригинальных ошибок, с которой я столкнулся и которая, скорее всего, была вызвана именно повторным удалением одного и того же указателя, было то, что программа, работающая несколько часов, рано или поздно "падала" в... функции malloc(). Причем проработать она должна была именно несколько часов, иначе эта ситуация не повторялась.

Таким образом, автоматическое удаление при гарантированном неиспользовании указателя, это явный плюс. В принципе, можно позавидовать программистам на Java, у которых подобных проблем не возникает; зато, у них возникают другие проблемы ;) 

Я еще не убедил вас в полезности использования "умных" указателей? Странно. Тогда я приведу примеры реального использования в своих проектах. Вот, например, объявление того самого "умного" указателя с подсчетом ссылок, о котором я говорил: 

template<class T>

class MPtr

{

public:

MPtr();

MPtr(const MPtr<T>& p);

~MPtr();

MPtr(T* p);

T* operator->() const;

operator T*() const;

MPtr<T>& operator=(const MPtr<T>& p);

protected:

struct RealPtr

{

T* pointer;

unsigned int count;

 

RealPtr(T* p = 0);

~RealPtr();

};

 

RealPtr* pointer;

private:

};

{mospagebreak} 

Особенно стоит оговорить здесь конструктор MPtr::MPtr(T* p), который несколько выбивается из общей концепции. Все дело в том, что гарантировать отсутствие указателей на реальный объект может лишь создание такого объекта где-то внутри, это сделано в MPtr::MPtr(), где вызов new происходит самостоятельно. В итоге некоторая уверенность в том, что значение указателя никто нигде не сохранил без использования умного указателя, все-таки есть. Тем не менее, очень часто встречается такое, что у типа T может и не быть конструктора по умолчанию и объекту такого класса обязательно при создании требуются какие-то аргументы для правильной инициализации. Совсем правильным будет для подобного случая породить из MPtr новый класс, у которого будут такие же конструкторы, как и у требуемого класса, но реально я на такое геройство ни разу не сподобился. Поэтому, сделав себе торжественную клятву, что подобный конструктор MPtr::MPtr(T* p) будет использоваться только как MPtr<T> ptr(new T(a,b,c)) и никак иначе, я ввел этот конструктор в шаблон.

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

Кроме всего прочего, в моем "умном" указателе отсутствует возможность приведения указателя от потомка к предку. Мне это не понадобилось, потому что "умные" указатели я использую обычно в интерфейсных классах, которые передают именно предков иерархий классов (т.е., соответствующее преобразование выполняется на этапе конструирования объекта); в принципе, несложно при помощи шаблонного конструктора сделать преобразование, аналогичное преобразованию реальных указателей.

Кроме MPtr я активно использую еще одну разновидность "умных" указателей, которая логично вытекает из описанной выше и отличается лишь одной деталью:


template<class T>

class MCPtr

{

public:

MCPtr(const MPtr<T>& p);

MCPtr(const MCPtr<T>& p);

~MCPtr();

const T* operator->() const;

operator const T*() const;

MCPtr<T>& operator=(const MPtr<T>& p)

MCPtr<T>& operator=(const MCPtr<T>& p);

protected:

MPtr<T> ptr;

private:

MCPtr();

};


Во-первых, это надстройка (адаптер) над обычным указателем. А во-вторых, его главное отличие, это то, что operator-> возвращает константный указатель, а не обычный. Это очень просто и, на самом деле, очень полезно: все дело в том, что это дает использовать объект в двух контекстах --- там, где его можно изменять (например, внтури другого объекта, где он был создан) и там, где можно пользоваться лишь константным интерфейсом (т.е., где изменять нельзя; например, снаружи объекта-фабрики). Это логично вытекает из простых константых указателей. Для того, что бы пользоваться MCPtr требуется единственное (хотя и достаточно строгое) условие: во всех классах должны быть корректно расставленна константность методов. Вообще, это, на мой взгляд, признак профессионального программиста: использование модификатора const при описании методов.

Обычно я использую "умные" указатели в том, что называется фабриками объектов (или, например, производящими функциями): т.е., для того, что бы вернуть объект, удовлетворяющий какому-то интерфейсу. При использовании подобного рода указателей клиентской части становится очень удобно --- опускаются все проблемы, связанные с тем, когда можно удалить объект, а когда нельзя (например, при соместном использовании одного и того же объекта разными клиентами --- клиенты не обязаны знать о существовании друг друга).

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

Конечно же, использовать "умные" указатели надо с осторожностью. Все дело в том, что, как у всякой простой идеи, у нее есть один очень большой недостаток: нетрудно придумать пример, в котором два объекта ссылаются друг на друга через "умные" указатели и... никогда не будут удалены. Почему? Потому что счетчики ссылок у них всегда будут как минимум 1, при том, что снаружи на них никто не ссылается. Есть рекомендации по поводу того, как определять такие ситуации во время выполнения программы, но, на мой взгляд, они очень громоздки и, поэтому не годятся к использованию. Ведь что привлекает в "умных" указателях? Простота. Фактически, ничего лишнего, а сколько можно при желании извлечь пользы из их применения.

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

"Умные" указатели активно используются в отображении COM-объектов и CORBA-объектов на C++: они позволяют прозрачно для программиста организовать работу с объектами, которые реально написаны на другом языке программирования и выполняются на другой стороне земного шара.

Техника подсчета ссылок в явном виде (через вызов методов интерфейса AddRef() и Release()) используется в технологии COM.

Еще стоит сказать про эффективность использования "умных" указателей. Возможно, это кого-то удивит, но затраты на их использование при выполнении программы минимальны. Почему? Потому что используются шаблоны а все методы-члены классов (и, в особенности селектор) конечно же объявлены как inline. Подсчет ссылок не сказывается на обращении к объекту, только на копировании указателей, а это не такая частая операция. Понятно, что использование шаблонов усложняет работу компилятора, но это не так важно. 

Резюме

Разумное использование "умных" указателей упрощает жизнь программистов. В частности, возможность введения подсчета ссылок, позволяет простым способом избавиться от многих ошибок, связанных с использованием динамической памяти.

Несмотря на присутствие в STL класса auto_ptr, "умные" указатели каждый программист должен закодировать для себя сам, потому что должен досконально точно понимать механизм их работы