Владение
В C++ деструкторы объектов с автоматической длительностью хранения вызываются всякий раз, когда заканчивается их область применения. Это свойство часто используется для автоматической очистки ресурсов в шаблоне, известном под не очень выразительным названием RAII.
Важной частью RAII является концепция владения ресурсом: объект, ответственный за очистку ресурса в его деструкторе, владеет этим ресурсом. Правильная политика владения — это секрет, позволяющий избежать утечек ресурсов.
Реализация политик владения может быть утомительной и чреватой ошибками. Было написано много статей и даже книг, чтобы дать рекомендации и правила, помогающие программистам справиться с этой задачей.
C++ хорош тем, что он позволяет нам инкапсулировать эти политики владения в общие классы многократного использования. Это может значительно сократить усилия по обработке прав собственности в других классах.
Семантика значения
Когда мы передаем объекты по значению в C++, они копируются. Если мы хотим сохранить значение в качестве члена, мы используем объект-член без указателя, чтобы сразу не столкнуться с висячими ссылками. Если мы хотим, чтобы функция обрабатывала копию аргумента, мы можем принять этот аргумент по значению. Если мы хотим работать с тем же объектом без создания копии, мы можем взять этот аргумент по ссылке (т.е. с типом ссылки).
Использование семантики значений очень важно для RAII, поскольку ссылки не влияют на время жизни своих референтов. К сожалению, семантика значений по умолчанию не очень хорошо взаимодействует с идеей владения, описанной выше.
Правило трех
Что происходит, когда создается копия объекта, владеющего ресурсом? По умолчанию выполняется членораздельное копирование. Это означает, что внутренний хэндл ресурса, который хранит объект, будет скопирован, в результате чего получится два объекта, владеющих одним и тем же ресурсом. Это, скорее всего, приведет к тому, что они оба попытаются освободить ресурс, и последует катастрофа.
К счастью, C++ позволяет программистам определять, что означает семантика значения для класса, предоставляя определяемые пользователем конструкторы копирования и операторы присвоения копий.
Чтобы добиться последовательного и корректного поведения, необходимо обратить пристальное внимание на то, как взаимодействуют эти определяемые пользователем функции. Эта идея широко известна как Правило трех:
Если класс определяет деструктор, конструктор копирования или оператор присвоения копий, то ему, вероятно, следует явно определить все три оператора, а не полагаться на их реализацию по умолчанию.
Чтобы разрешить этот конфликт между семантикой значения и правом собственности, можно использовать несколько подходов. Общие варианты включают:
- не допускать создания копий;
- предоставлять пользовательскую семантику копирования, которая копирует ресурс;
- сделать так, чтобы оба объекта имели право собственности на ресурс;
- сделать копирование передачей ресурса;
Вариант №1 дает исключительное право собственности на ресурс одному объекту и не позволяет передать это право другому объекту. Это довольно ограничивает возможности, поскольку не позволяет передавать или возвращать эти ресурсы по значению, а требует использования указателей или ссылок. Если сделать его членом другого класса, то этот класс также станет некопируемым, даже если копии желательны. Примером класса с такой политикой является boost::scoped_ptr
.
Вариант №2 также дает исключительное право собственности на один объект, но эмулирует «нормальную» семантику значений для ресурса. Это полезно для полиморфных классов, чтобы избежать проблемы срезов, и для других ресурсов, для которых можно легко сделать копию (дескрипторы файлов POSIX — возможный пример, поскольку их можно копировать с помощью dup
).
Вариант №3 разделяет права собственности между двумя объектами. Этот вариант может быть довольно сложным для реализации, особенно в потокобезопасной манере. Обычные реализации поддерживают счетчик ссылок, который отслеживает, сколько экземпляров владеют ресурсом, и удаляют его, когда он достигает нуля. И Boost, и последний стандарт предоставляют такую политику в форме shared_ptr
.
Вариант №4 дает исключительное право собственности, но позволяет передавать это право. Именно так поступает std::auto_ptr
, и известно, что это не очень хороший подход (на самом деле, std::auto_ptr
сейчас является устаревшей функцией).
Очевидно, что можно придумать гораздо более разумную политику владения.
Правило пяти
В C++11 появились дополнительные возможности для политики владения. С семантикой перемещения мы теперь можем предоставлять варианты передачи права собственности между объектами, более безопасные, чем вариант #4 выше.
Вариант №1 можно улучшить, чтобы позволить передавать права собственности, добавив конструктор перемещения и оператор присвоения перемещения. Это делает его гораздо более удобным и полезным. Его можно возвращать из функций и передавать по значению для передачи права собственности. Примером может служить std::unique_ptr
, первый выбор, когда нужен умный указатель.
Вариант #2 также может выиграть от наличия специальных членов перемещения, поскольку это позволяет избежать ненужных копий. В варианте #3 это дает возможность передавать права собственности без необходимости обновлять счетчики ссылок.
Вариант №4 был не очень хорош для начала. Поскольку он в основном сводится к попытке семантики перемещения с помощью специальных членов copy, вариант №1 предпочтительнее. Именно по этой причине std::auto_ptr
был устаревшим в пользу std::unique_ptr
.
Правило нуля
Правило трех и твердое понимание различных стратегий владения очень важны для написания хорошо управляемых классов и программ. Однако, если пытаться руководствоваться только этими правилами, добиться правильного результата часто бывает непросто и вскоре может стать непосильной задачей. Слишком сильная фиксация на них может привести к тому, что человек не увидит леса за деревьями.
Я бы даже сказал, что эти правила нужны очень редко. Почему это так? Ну, как я уже говорил во введении о владении, C++ позволяет нам инкапсулировать политику владения в общие классы многократного использования. Это очень важный момент! Чаще всего наши потребности во владении могут быть удовлетворены классами «ownership-in-a-package».
Общие классы «ownership-in-a-package» включены в стандартную библиотеку: std::unique_ptr
и std::shared_ptr
. Благодаря использованию пользовательских объектов deleter, оба класса стали достаточно гибкими для управления практически любым видом ресурсов.
В качестве примера давайте напишем класс, который владеет ресурсом HMODULE
из Windows API. HMODULE
(псевдоним типа void*
) — это обращение к загруженному модулю, обычно DLL. Он должен быть освобожден путем передачи его в функцию FreeLibrary
.
Одним из вариантов может быть реализация класса, который следует правилу трех (или пяти).
class module { public: explicit module(std::wstring const& name) : handle { ::LoadLibrary(name.c_str()) } {} // move constructor module(module&& that) : handle { that.handle } { that.handle = nullptr; } // copy constructor is implicitly forbidden due to user-defined move constructor // move assignment module& operator=(module&& that) { module copy { std::move(that) }; std::swap(handle, copy.handle); return *this; } // copy assignment is implicitly forbidden due to user-defined move assignment // destructor ~module() { ::FreeLibrary(handle); } // other module related functions go here private: HMODULE handle; };
Но зачем это делать, если можно просто взять существующую многоразовую политику владения и получить тот же эффект?
class module { public: explicit module(std::wstring const& name) : handle { ::LoadLibrary(name.c_str()), &::FreeLibrary } {} // other module related functions go here private: using module_handle = std::unique_ptr<void, decltype(&::FreeLibrary)>; module_handle handle; };
Хотите верьте, хотите нет, но это делает то же самое, что и первая версия, но все пожизненные члены были неявно определены компилятором. Это имеет несколько преимуществ:
- Отсутствие дублирования логики владения;
- Отсутствие смешивания права собственности с другими проблемами;
- Меньше кода, меньше возможностей для ошибок; не нужно пересматривать/изменять код при изменении класса;
- И самое главное, мы получили безопасность исключений бесплатно.
Чтобы проиллюстрировать последний пункт, рассмотрим класс, который владеет двумя ресурсами.
struct something { something() : resource_one { new foo } , resource_two { new foo } // what if this throws? {} // blah foo* resource_one; foo* resource_two; };
Если при построении resource_two
происходит сбой, то resource_one
необходимо очистить. Это, конечно, можно сделать, но это еще больше усложняет код и легко упускается из виду.
Вместо этого, если мы повторно используем политики владения (например, используя std::unique_ptr<foo> вместо foo*), очистка будет автоматической даже при наличии исключений во время конструирования.
Если нам нужна какая-то политика владения, которую не обеспечивают существующие классы, мы можем просто написать новый класс «ownership-in-a-package» и получить те же преимущества. Код должен будет где-то работать с этим правом собственности, и если оно будет изолировано в отдельном месте, то его будет легче поддерживать.
Итак, мы пришли к правилу нуля (которое на самом деле является частным случаем принципа единой ответственности):
Классы, имеющие пользовательские деструкторы, конструкторы копирования/перемещения или операторы присвоения копирования/перемещения, должны иметь дело исключительно с правом владения. Другие классы не должны иметь пользовательских деструкторов, конструкторов копирования/перемещения или операторов присвоения копирования/перемещения.