Правило нуля

294
Правило нуля
Правило нуля

Владение

В C++ деструкторы объектов с автоматической длительностью хранения вызываются всякий раз, когда заканчивается их область применения. Это свойство часто используется для автоматической очистки ресурсов в шаблоне, известном под не очень выразительным названием RAII.

Важной частью RAII является концепция владения ресурсом: объект, ответственный за очистку ресурса в его деструкторе, владеет этим ресурсом. Правильная политика владения — это секрет, позволяющий избежать утечек ресурсов.

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

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

Семантика значения

Когда мы передаем объекты по значению в C++, они копируются. Если мы хотим сохранить значение в качестве члена, мы используем объект-член без указателя, чтобы сразу не столкнуться с висячими ссылками. Если мы хотим, чтобы функция обрабатывала копию аргумента, мы можем принять этот аргумент по значению. Если мы хотим работать с тем же объектом без создания копии, мы можем взять этот аргумент по ссылке (т.е. с типом ссылки).

Использование семантики значений очень важно для RAII, поскольку ссылки не влияют на время жизни своих референтов. К сожалению, семантика значений по умолчанию не очень хорошо взаимодействует с идеей владения, описанной выше.

Правило трех

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

К счастью, C++ позволяет программистам определять, что означает семантика значения для класса, предоставляя определяемые пользователем конструкторы копирования и операторы присвоения копий.

Чтобы добиться последовательного и корректного поведения, необходимо обратить пристальное внимание на то, как взаимодействуют эти определяемые пользователем функции. Эта идея широко известна как Правило трех:

Если класс определяет деструктор, конструктор копирования или оператор присвоения копий, то ему, вероятно, следует явно определить все три оператора, а не полагаться на их реализацию по умолчанию.

Чтобы разрешить этот конфликт между семантикой значения и правом собственности, можно использовать несколько подходов. Общие варианты включают:

  1. не допускать создания копий;
  2. предоставлять пользовательскую семантику копирования, которая копирует ресурс;
  3. сделать так, чтобы оба объекта имели право собственности на ресурс;
  4. сделать копирование передачей ресурса;

Вариант №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» и получить те же преимущества. Код должен будет где-то работать с этим правом собственности, и если оно будет изолировано в отдельном месте, то его будет легче поддерживать.

Итак, мы пришли к правилу нуля (которое на самом деле является частным случаем принципа единой ответственности):

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