Семантика копирования и управление ресурсами в C++


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




Оглавление


Оглавление

  Введение
  1. Основные стратегии копирования-владения
    1.1. Стратегия запрета копирования
    1.2. Стратегия исключительного владения
    1.3. Стратегия глубокого копирования
    1.4. Стратегия совместного владения
  2. Стратегия глубокого копирования — проблемы и решения
    2.1. Копирование при записи
    2.2. Определение функции обмена состояниями для класса
    2.3. Удаление промежуточных копий компилятором
    2.4. Реализация семантики перемещения
    2.5. Размещение vs. вставки
    2.6. Итоги
  3. Возможные варианты реализации стратегии совместного владения
  4. Стратегия исключительного владения и семантика перемещения
  5. Стратегия запрета копирования — быстрое начало
  6. Жизненный цикл ресурса и объекта-владельца ресурса
    6.1. Захват ресурса при инициализации
    6.2. Расширенные варианты управления жизненным циклом ресурса
      6.2.1. Расширенный жизненный цикл ресурса
      6.2.2. Однократный захват ресурса
      6.2.3. Повышение уровня косвенности
    6.3. Совместное владение
  7. Итоги
  Приложения
    Приложение A. Rvalue-ссылки
    Приложение Б. Семантика перемещения
  Список литературы




Введение


Управление ресурсами — это то, чем программисту на C++ приходится заниматься постоянно. К ресурсам можно отнести блоки памяти, объекты ядра ОС, многопоточные блокировки, сетевые соединения, соединения с БД и просто любой объект, созданный в динамической памяти. Доступ к ресурсу осуществляется через дескриптор, тип дескриптора обычно указатель или один из его псевдонимов (HANDLE, etc.), иногда целый (файловые дескрипторы UNIX). После использования ресурс необходимо освобождать, иначе рано или поздно приложение, не освобождающее ресурсы (а возможно и другие приложения), столкнется с нехваткой ресурсов. Проблема эта весьма острая, можно сказать, что одной из ключевых особенностей платформ .NET, Java и ряда других является унифицированная система управления ресурсами, основанная на сборке мусора.


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


К счастью, в C++ программист может полностью контролировать процесс копирования путем собственного определения копирующего конструктора и оператора копирующего присваивания, что позволяет решить вышеописанную проблему, причем обычно не одним способом. Реализация копирования должна быть тесно увязана с механизмом освобождения ресурса, и это все вместе будем называть стратегией копирования-владения. Хорошо известно так называемое «правило большой тройки», которое утверждает, что если программист определил хотя бы одну из трех операций — копирующий конструктор, оператор копирующего присваивания или деструктор, — то он должен определить все три операции. Стратегии копирования-владения как раз и конкретизируют, как это надо делать. Существует четыре основных стратегии копирования-владения.



1. Основные стратегии копирования-владения


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



1.1. Стратегия запрета копирования


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


class X
{
private:
    X(const X&);
    X& operator=(const X&);
// ...
};

Попытки копирования пресекаются компилятором и компоновщиком.


Стандарт C++11 предлагает для этого случая специальный синтаксис:


class X
{
public:
    X(const X&) = delete;
    X& operator=(const X&) = delete;
// ...
};

Этот синтаксис более нагляден и дает более понятные сообщения компилятора при попытке копирования.


В предыдущей версии стандартной библиотеки (C++98) стратегию запрета копирования использовали классы потоков ввода-вывода (std::fstream, etc.), а в ОС Windows многие классы из MFC (CFile, CEvent, CMutex, etc.). В стандартной библиотеке C++11 эту стратегию используют некоторые классы для поддержки многопоточной синхронизации.



1.2. Стратегия исключительного владения


В этом случае при реализации копирования и присваивания дескриптор ресурса перемещается от объекта-источника к целевому объекту, то есть остается в единственном экземпляре. После копирования или присваивания, объект-источник имеет нулевой дескриптор и не может использовать ресурс. Деструктор освобождает захваченный ресурс. Для этой стратегии также используются термины эксклюзивное или строгое владение [Josuttis], Андрей Александреску [Alexandrescu] использует термин разрушающее копирование. В C++11 это делается следующим образом: запрещается обычное копирование и копирующее присваивание вышеописанным способом, и реализуются семантики перемещения, то есть определяются перемещающий конструктор и оператор перемещающего присваивания. (Подробнее о семантике перемещения далее.)


class X
{
public:
    X(const X&) = delete;
    X& operator=(const X&) = delete;
    X(X&& src) noexcept;
    X& operator=(X&& src) noexcept;
// ...
};

Таким образом, стратегию исключительного владения можно считать расширением стратегии запрета копирования.


В стандартной библиотеке C++11 эту стратегию использует интеллектуальный указатель std::unique_ptr<> и некоторые другие классы, например: std::thread, std::unique_lock<>, а также классы, ранее использовавшие стратегию запрета копирования (std::fstream, etc.). В ОС Windows классы MFC, ранее использовавшие стратегию запрета копирования, также стали использовать стратегию исключительного владения (CFile, CEvent, CMutex, etc.).



1.3. Стратегия глубокого копирования


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


Эта стратегия применима не ко всем ресурсам. Ее можно применять к ресурсам, связанным с буфером памяти, например строкам, но не очень понятно, как ее применять к объектам ядра ОС типа файлов, мьютексов и т.д.


Стратегия глубокого копирования используется во всех типах объектных строк, std::vector<> и других контейнерах стандартной библиотеки.



1.4. Стратегия совместного владения


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


Стратегию совместного владения часто используют интеллектуальные указатели, ее также естественно использовать для неизменяемых (immutable) ресурсов. В стандартной библиотеке C++11 эту стратегию реализует интеллектуальный указатель std::shared_ptr<>.



2. Стратегия глубокого копирования — проблемы и решения


Рассмотрим шаблон функции обмена состояниями объектов типа T в стандартной библиотеке C++98.


template<typename T>
void swap(T& a, T& b)
{
    T tmp(a);
    a = b;
    b = tmp;
}

Если тип T владеет ресурсом и использует стратегию глубокого копирования, то мы имеем три операции выделения нового ресурса, три операции копирования и три операции освобождения ресурсов. Тогда как в большинстве случаев эту операцию можно осуществить вообще без выделения новых ресурсов и копирования, достаточно объектам обменяться внутренними данными, включая дескриптор ресурса. Подобных примеров, когда приходится создавать временные копии ресурса и тут же их освобождать, можно привести много. Столь неэффективная реализация повседневных операций стимулировала поиск решений для их оптимизации. Рассмотрим основные варианты.



2.1. Копирование при записи


Копирование при записи (copy on write, COW), называемое также отложенным копированием, можно рассматривать как попытку соединить стратегию глубокого копирования и стратегию совместного владения. Первоначально при копировании объекта копируется дескриптор ресурса, без самого ресурса, и для владельцев ресурс становится разделяемым и доступным в режиме «только для чтения», но как только какому-нибудь владельцу потребуется модифицировать разделяемый ресурс, выполняется копирование ресурса и далее этот владелец работает со своей копией. Реализация COW решает проблему обмена состояниями: дополнительного выделения ресурсов и копирования при этом не происходит. Использование COW достаточно популярно при реализации строк, в качестве примера можно привести CString (MFC, ATL). Обсуждение возможных путей реализации COW и возникающих проблем можно найти в [Meyers1], [Sutter]. В [Guntheroth] предложен вариант реализации COW с использованием std::shared_ptr<>. Имеются проблемы при реализация COW в многопоточной среде, из-за чего в стандартной библиотеке C++11 для строк запрещено использовать COW, см. [Josuttis], [Guntheroth].


Развитие идеи COW приводит к следующей схеме управления ресурсом: ресурс является неизменяемым (immutable) и управляется объектами, использующими стратегию совместного владения, при необходимости изменить ресурс создается новый, соответствующим образом измененный ресурс, и возвращается новый объект-владелец. Эта схема используется для строк и других неизменяемых объектов на платформах .NET и Java. В функциональном программировании она используется для более сложных структур данных.



2.2. Определение функции обмена состояниями для класса


Выше было показано, насколько неэффективно может работать функция обмена состояниями, реализованная прямолинейно, через копирование и присваивание. А используется она достаточно широко, например, ее применяют многие алгоритмы стандартной библиотеки. Для того, чтобы алгоритмы использовали не std::swap(), а другую функцию, специально определенную для класса, необходимо выполнить два шага.


1. Определить в классе функцию-член Swap() (имя не принципиально), реализующую обмен состояниями.


class X
{
public:
    void Swap(X& other) noexcept;
// ...
};

Необходимо гарантировать, чтобы эта функция не выбрасывала исключения, в C++11 такие функции надо объявлять как noexcept.


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


inline void swap(X& a, X& b) noexcept { a.Swap(b); }

После этого алгоритмы стандартной библиотеки будут использовать ее, а не std::swap(). Это обеспечивает механизм, называемый поиском, зависимым от типов аргументов (argument dependent lookup, ADL). Подробнее об ADL см. [Dewhurst1].


В стандартной библиотеке C++ все контейнеры, интеллектуальные указатели, а также другие классы реализуют функцию обмена состояниями описанным выше способом.


Функция-член Swap() определяется обычно легко: необходимо последовательно применять к базам и членам операцию обмена состояниями, если они ее поддерживают, и std::swap() в противном случае.


Приведенное описание несколько упрощено, более детальное можно найти в [Meyers2]. Обсуждение проблем, связанных с функцией обмена состояниями, также можно найти в [Sutter/Alexandrescu].


Функцию обмена состояниями можно отнести к одной из базовых операций класса. С помощью нее можно изящно определить другие операции. Например, оператор копирующего присваивания определяется через копирование и Swap() следующим образом:


X& X::operator=(const X& src)
{
    X tmp(src);
    Swap(tmp);
    return *this;
}

Этот шаблон называется идиомой «копирование и обмен» или идиомой Герба Саттера, подробнее см. [Sutter], [Sutter/Alexandrescu], [Meyers2]. Его модификацию можно применить для реализации семантики перемещения, см. разделы 2.4, 2.6.1.



2.3. Удаление промежуточных копий компилятором


Рассмотрим класс


class X
{
public:
    X(/* параметры */);
// ...
};

И функцию


X Foo()
{
// ...
    return X(/* аргументы */);
}

При прямолинейном подходе возврат из функции Foo() реализуется через копирование экземпляра X. Но компиляторы умеют удалять из кода операцию копирования, объект создается непосредственно в точке вызова. Это называется оптимизацией возвращаемого значения (return value optimization, RVO). RVO применяется разработчиками компиляторов достаточно давно и в настоящее время зафиксирована в стандарте C++11. Хотя решение об RVO принимает компилятор, программист может писать код в расчете на ее использование. Для этого желательно, чтобы функция имела одну точку возврата и тип возвращаемого выражения совпадал с типом возвращаемого значения функции. В ряде случаев целесообразно определить специальный закрытый конструктор, называемый «вычислительным конструктором», подробнее см. [Dewhurst2]. RVO также обсуждается в [Meyers3] и [Guntheroth].


Компиляторы могут удалять промежуточные копии и в других ситуациях.



2.4. Реализация семантики перемещения


Реализация семантики перемещения заключается в определении перемещающего конструктора, имеющего параметр типа rvalue-ссылка на источник и оператора перемещающего присваивания с таким же параметром.


В стандартной библиотеке C++11 шаблон функции обмена состояниями определен следующим образом:


template<typename T>
void swap(T& a, T& b)
{
    T tmp(std::move(a));
    a = std::move(b);
    b = std::move(tmp);
}

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


Использование семантики перемещения позволяет избежать создания временных копий в значительно более широком контексте, чем описанная выше функция обмена состояниями. Семантика перемещения применяется к любому rvalue-значению, то есть временному, неименованному значению, а также к возвращаемому значению функции, если оно создано локально (в том числе и lvalue), и при этом не было применено RVO. Во всех этих случаях гарантируется, что объект-источник не сможет быть как-либо использован после выполнения перемещения. Семантика перемещения также применяется к lvalue-значению, к которому применено преобразование std::move(). Но в этом случае программист сам отвечает за то, как объекты-источники будут использоваться после перемещения (пример std::swap()).


Стандартная библиотека C++11 переработана с учетом семантики перемещения. Во многие классы добавлены перемещающий конструктор и оператор перемещающего присваивания, а также другие функции-члены, с параметрами типа rvalue-ссылка. Например, std::vector<T> имеет перегруженную версию void push_back(T&& src). Все это позволяет во многих случаях избегать создания временных копий.


Реализация семантики перемещения не отменяет определения функции обмена состояниями для класса. Специально определенная функция обмена состояниями может быть эффективнее, чем стандартная std::swap(). Более того, перемещающий конструктор и оператор перемещающего присваивания очень просто определяются с помощью функции-члена обмена состояниями следующим образом (вариация идиомы «копирование и обмен»):


class X
{
public:
    X() noexcept {/* инициализация нулевого дескриптора */}
    void Swap(X& other) noexcept {/* обмен состояниями */}

    X(X&& src) noexcept : X()
    {
        Swap(src);
    }

    X& operator=(X&& src) noexcept
    {
        X tmp(std::move(src)); // перемещение
        Swap(tmp);
        return *this;
    }
// ...
};

Перемещающий конструктор и оператор перемещающего присваивания относятся к тем функциям-членам, для которых крайне желательно гарантировать, чтобы они не выбрасывали исключений, и, соответственно, были объявлены как noexcept. Это позволяет оптимизировать некоторые операции контейнеров стандартной библиотеки без нарушений строгой гарантии безопасности исключений, подробнее см. [Meyers3] и [Guntheroth]. Предлагаемый шаблон дает такую гарантию при условии, что конструктор по умолчанию и функция-член обмена состояниями не выбрасывают исключений.


Стандарт C++11 предусматривает автоматическую генерацию компилятором перемещающего конструктора и оператора перемещающего присваивания, для этого их надо объявить с использованием конструкции "=default".


class X
{
public:
    X(X&&) = default;
    X& operator=(X&&) = default;
// ...
};

Операции реализуются путем последовательного применения операции перемещения к базам и членам класса, если они поддерживают перемещение, и операции копирования в противном случае. Понятно, что такой вариант далеко не всегда приемлем. Сырые дескрипторы не перемещаются, но копировать их обычно нельзя. При выполнении определенных условий компилятор может самостоятельно сгенерировать подобный перемещающий конструктор и оператор перемещающего присваивания, но этой возможностью лучше не пользоваться, условия эти довольно запутаны и легко могут измениться при доработке класса. Подробнее см. [Meyers3].


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


  1. По возможности использовать запрет копирования.
  2. Объявлять перемещающий конструктор и оператор перемещающего присваивания как noexcept.
  3. Реализовать семантику перемещения для базовых классов и членов.
  4. Применять преобразование std::move() к параметрам функций, имеющих тип rvalue-ссылка.

Правило 2 обсуждалось выше. Правило 4 связано с тем, что именованные rvalue-ссылки являются lvalue (см. также Приложение А). Это можно проиллюстрировать на примере определения перемещающего конструктора.


class B
{
// ...
    B(B&& src) noexcept;
};

class D : public B
{
// ...
    D(D&& src) noexcept;
};

D::D(D&& src) noexcept
    : B(std::move(src)) // перемещение
{/* ... */}

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



2.5. Размещение vs. вставки


Идея размещения похожа на идею, лежащую в основе RVO (см. раздел 2.3), но применяется она не к возвращаемому значению функции, а к входным параметрам. При традиционной вставке объекта в контейнер сначала создается объект (часто временный), затем копируется или перемещается в место хранения, после чего временный объект уничтожается. При размещении объект создается сразу в месте хранения, передаются только аргументы конструктора. Контейнеры стандартной библиотеки C++11 имеют функции-члены emplace(), emplace_front(), emplace_back(), работающие таким образом. Естественно, что это шаблонные функции-члены с переменным числом шаблонных параметров — вариативные шаблоны (variadic templates), так как количество и тип параметров конструктора заранее неизвестно. Кроме того, используются и другие продвинутые техники C++11 — прямая передача и универсальные ссылки.


Размещение имеет следующие преимущества:


  1. Для объектов, не поддерживающих перемещение, исключается операция копирования.
  2. Для объектов, поддерживающих перемещение, размещение почти всегда более эффективно.

Приведем пример, где одна и та же задача решается разными способами.


std::vector<std::string> vs;
vs.push_back(std::string(3, ’X’)); // вставка
vs.emplace_back(3, ’7’);           // размещение

В случае вставки создается временный объект std::string, затем перемещается в место хранения и после этого временный объект уничтожается. При размещении объект создается сразу в месте хранения. Размещение выглядит более лаконично и, скорее всего, более эффективно. Скотт Мейерс детально рассматривает особенности размещения, прямой передачи и универсальных ссылок в [Meyers3].



2.6. Итоги


Одной из главных проблем классов, реализующих стратегию глубокого копирования, является создание временных копий ресурса. Ни один из описанных способов полностью не решает эту проблему и полностью не замещает какой-то другой способ. В любом случае программист должен распознавать подобные ситуации и писать правильный код с учетом описанной проблемы и возможностей языка. Простейший пример — это передача параметров в функцию: передавать надо по ссылке, а не по значению. Эта ошибка не распознается компилятором, но при этом происходит либо ненужное копирование, либо программа работает не так, как задумано. Другой пример связан с использованием перемещения: программист должен четко соблюдать условия, при которых компилятор выбирает перемещение, иначе «молча» будет использовано копирование.


Описанные проблемы позволяют сделать следующую рекомендацию: необходимо по возможности избегать стратегии глубокого копирования, реальная потребность в глубоком копировании возникает весьма редко, это подтверждает опыт программирования на платформах .NET и Java. В качестве альтернативного варианта можно предложить реализацию глубокого копирования с помощью специальной функции, традиционное название для таких функций Clone() или Duplicate().


Если все-таки при реализации класса-владельца ресурса принято решение использовать стратегию глубокого копирования, то кроме реализации семантики копирования можно рекомендовать следующие шаги:


  1. Определить функцию обмена состояниями.
  2. Определить перемещающий конструктор и оператор перемещающего присваивания.
  3. Определить необходимые функции-члены и свободные функции с параметрами типа rvalue-ссылка.

На платформах .NET и Java основной стратегией копирования-владения является стратегия совместного владения, но при необходимости можно реализовать и стратегию глубокого копирования, например в .NET для этого надо реализовать интерфейс IClonable. Как было отмечено выше, потребность в этом возникает достаточно редко.



3. Возможные варианты реализации стратегии совместного владения


Довольно просто реализовать стратегию совместного владения для ресурса, имеющего внутренний счетчик ссылок. В этом случае при копировании объекта-владельца ресурса счетчик ссылок инкрементируется, а в деструкторе декрементируется. Когда его значение достигает нуля, ресурс сам себя освобождает. Внутренний счетчик ссылок используют базовые ресурсы ОС Windows: объекты ядра ОС, управляемые через HANDLE, и COM-объекты. Для объектов ядра счетчик ссылок инкрементируется с помощью функции DuplicateHandle(), а декрементируется с помощью функции CloseHandle(). Для COM-объектов используются функции-члены IUnknown::AddRef() и IUnknown::Release(). В библиотеке ATL есть интеллектуальный указатель CСomPtr<>, управляющий COM-объектами таким способом. Для файловых дескрипторов UNIX, открытых с помощью функций стандартной библиотеки C, счетчик ссылок инкрементируется функцией _dup(), декрементируется с помощью функции закрытия файла.


В стандартной библиотеке C++11 интеллектуальный указатель std::shared_ptr<> также использует счетчик ссылок. Но объект, контролируемый этим интеллектуальным указателем, может не иметь внутреннего счетчика ссылок, поэтому создается специальный скрытый объект, называемый управляющим блоком, который управляет счетчиком ссылок. Понятно, что это является дополнительным накладным расходом. Интеллектуальный указатель std::shared_ptr<> подробно описан в [Josuttis], [Meyers3].


Использование счетчика ссылок имеет генетический дефект: если объекты-владельцы ресурсов имеют взаимные ссылки друг на друга, то их счетчики ссылок никогда не будут равны нулю (проблема циклических ссылок). В ряде случаев ресурсы не могут иметь взаимных ссылок (например объекты ядра ОС) и поэтому эта проблема не актуальна, но в остальных случаях программист сам должен отслеживать подобные ситуации и принимать необходимые меры. При использовании std::shared_ptr<> для этих целей предлагается использовать вспомогательный интеллектуальный указатель std::weak_ptr<>. Подробнее см. [Josuttis], [Meyers3].


Андрей Александреску рассматривает реализацию стратегии совместного владения с помощью двусвязного списка объектов-владельцев [Alexandrescu]. Герберт Шилдт описывает (и приводит полный код) реализации, основанной на комбинации двусвязного списка и счетчика ссылок [Schildt]. Реализации на основе двусвязного списка также не могут освободить ресурсы, имеющие циклические ссылки.


Описание более сложных схем удаления неиспользуемых объектов (сборщиков мусора) можно найти в [Alger].


Реализация стратегии совместного владения также должна учитывать возможность многопоточного доступа к объектам-владельцам. Эта тема обсуждается в [Josuttis] и [Alexandrescu].


Стратегия совместного владения является основной стратегией копирования-владения на платформах .NET и Java. Компонент исполняющей среды, занимающейся удалением неиспользуемых объектов, называется сборщиком мусора, запускается периодически и использует сложные алгоритмы анализа графа объектов.



4. Стратегия исключительного владения и семантика перемещения


Безопасная реализация стратегии исключительного владения стала возможна только после того, как C++ стал поддерживать rvalue-ссылки и семантику перемещения. В стандартной библиотеке C++98 был интеллектуальный указатель std::auto_ptr<>, который реализовывал стратегию исключительного владения, но он имел ограниченное применение, в частности его нельзя было хранить в контейнерах. Дело в том, что он мог переместить указатель от объекта, которому этот указатель был еще нужен (попросту говоря украсть). В C++11 правила использования rvalue-ссылок гарантируют, что перемещать данные можно только от временного неименованного объекта, иначе будет ошибка компиляции. Поэтому в стандартной библиотеке C++11 std::auto_ptr><> объявлен устаревшим и вместо него рекомендовано использовать std::unique_ptr<>. Этот интеллектуальный указатель реализует стратегию исключительного владения на основе семантики перемещения, он подробно описан в [Josuttis], [Meyers3].


Стратегию исключительного владения также поддерживают некоторые другие классы: классы потоков ввода-вывода (std::fstream, etc.), классы для работы с потоками управления (std::thread, std::unique_lock<>, etc.). В MFC эту стратегию стали использовать классы, ранее использовавшие стратегию запрета копирования (CFile, CEvent, CMutex, etc.).



5. Стратегия запрета копирования — быстрое начало


На первый взгляд стратегия запрета копирования сильно ограничивает программиста. Но в реальности оказывается, что очень многие объекты не нуждаются в копировании. Поэтому, при проектировании класса, управляющего ресурсами, в качестве начального решения можно рекомендовать выбор стратегии запрета копирования. Если потребность в копировании возникает, то компилятор сразу это обнаружит, после чего можно проанализировать, для чего нужно копирование (и нужно ли оно вообще) и сделать необходимые доработки. В ряде случаев, например, при передаче по стеку вызовов, можно использовать ссылку. При необходимости хранить объекты в контейнерах стандартной библиотеки можно использовать указатели (лучше интеллектуальные) на объекты, созданные в динамической памяти. Вообще использование динамической памяти и интеллектуальных указателей является достаточно универсальным вариантом, который может помочь и в других случаях. Более сложный вариант — реализация семантики перемещения. Детали обсуждаются в разделе 6.


Небрежный программный дизайн, при котором не реализовывается какая-либо стратегия копирования-владения, зачастую «сходит с рук», потому что объекты-владельцы ресурса в действительности не копируются. В этом случае запрет копирования или другая стратегия копирования-владения внешне ничего не меняет. Но, тем не менее, это все равно надо делать, надо всегда писать правильный код, даже если в каком-то контексте неправильный код не проявляет своих дефектов. Неправильный код рано или поздно «выстрелит».



6. Жизненный цикл ресурса и объекта-владельца ресурса


Во многих случаях важно понимать, как соотносится жизненный цикл ресурса и объекта-владельца ресурса. Естественно, что это тесно связано со стратегией копирования-владения. Рассмотрим несколько вариантов.



6.1. Захват ресурса при инициализации


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


  1. Захват ресурса происходит только в конструкторе класса. При неудачном захвате выбрасывается исключение, и объект не создается.
  2. Освобождение ресурса происходит только в деструкторе.
  3. Копирование и перемещение запрещено.

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


Эта схема управления ресурсом является одним из вариантов идиомы «захват ресурса при инициализации» (resource acquisition is initialization, RAII). Идиома RAII широко обсуждается во многих книгах и в интернете (и часто трактуется немного по разному или просто не вполне четко), см., например [Dewhurst1]. Приведенный выше вариант можно назвать «строгим» RAII. В таком классе дескриптор ресурса естественно сделать константным членом, и, соответственно, можно использовать термин неизменяемое (immutable) RAII.



6.2. Расширенные варианты управления жизненным циклом ресурса


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



6.2.1. Расширенный жизненный цикл ресурса


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


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

В стандартной библиотеке C++11 расширенный жизненный цикл ресурса поддерживают строки, контейнеры, интеллектуальные указатели, а также некоторые другие классы. Но надо иметь в виду, что функция-член clear(), реализованная в строках и контейнерах, уничтожает все хранимые объекты, но может не освобождать резервируемую память. Для полного освобождения всех ресурсов надо принять дополнительные меры. Например, можно использовать shrink_to_fit(), или просто присвоить объект, созданный конструктором по умолчанию (см. ниже).


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


class X
{
public:
// RAII
    X(const X&) = delete;            // запрет копирования
    X& operator=(const X&) = delete; // запрет присваивания

    X(/* параметры */);              // захватывает ресурс
    ~X();                            // освобождает ресурс
// добавляем
    X() noexcept;                    // обнуляет дескриптор ресурса
    X(X&& src) noexcept              // перемещающий конструктор
    X& operator=(X&& src) noexcept;  // оператор перемещающего присваивания
// ...
};

После этого расширенный жизненный цикл ресурса реализуется совсем просто.


X x;                    // создание "пустого" объекта
x = X(/* аргументы */); // захват ресурса
x = X(/* аргументы */); // захват нового ресурса, освобождение текущего
x = X();                // освобождение ресурса

Именно так реализован класс std::thread.


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


class X
{
// RAII
// ...
public: // добавляем, вариант с использованием обмена состояниями
    X() noexcept;
    X(X&& src) noexcept;
    X& operator=(X&& src) noexcept;
    void Swap(X& other) noexcept; // обменивает состояния
    void Create(/* параметры */); // захватывает ресурс
    void Close() noexcept;        // освобождает ресурс
// ...
};

X::X() noexcept {/* инициализация нулевого дескриптора */}

Определение перемещающего конструктора и оператора перемещающего присваивания:


X::X(X&& src) noexcept : X()
{
    Swap(src);
}

X& X::operator=(X&& src) noexcept
{
    X tmp(std::move(src)); // перемещение
    Swap(tmp);
    return *this;
}

Определение отдельных функций-членов захвата и освобождения ресурса:


void X::Create(/* параметры */)
{
    X tmp(/* аргументы */); // захват ресурса
    Swap(tmp);
}

void X::Close() noexcept
{
    X tmp;
    Swap(tmp);
}

Следует обратить внимание, что в описанном шаблоне захват ресурса всегда происходит в конструкторе, а освобождение в деструкторе, функция-член обмена состояниями играет чисто техническую роль. Это упрощает и делает более надежным кодирование захвата и освобождения ресурса, так как, компилятор берет на себя часть логики реализации, особенно в деструкторе. В деструкторе компилятор обеспечивает вызов деструкторов для членов и баз в порядке обратном вызову конструкторов, что почти всегда гарантирует отсутствие ссылок на удаленные объекты.


В приведенных выше примерах определения оператора копирующего присваивания и функции-члена захвата ресурса использовалась идиома «копирование и обмен», в соответствии с которой сначала захватывается новый ресурс, потом освобождается старый. Эта схема обеспечивает так называемую строгую гарантию безопасности исключений: если при захвате ресурса произошло исключение, то объект останется в том же состоянии, что и до начала операции (транзакционная семантика). В определенных ситуациях может оказаться более предпочтительной другая схема: сначала освобождается старый ресурс, затем захватывается новый. Такой вариант обеспечивает более слабую гарантию безопасности исключений, называемую базовой: если при захвате ресурса произошло исключение, то объект уже не обязательно останется в том же состоянии, но новое состояние будет корректным. Кроме того, при определении оператора копирующего присваивания по этой схеме необходима проверка на самоприсваивание. Подробнее гарантии безопасности исключений обсуждаются в [Sutter], [Sutter/Alexandrescu], [Meyers2].


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



6.2.2. Однократный захват ресурса


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


  1. Имеется конструктор по умолчанию, не захватывающий ресурс.
  2. Имеется механизм захвата ресурса после создания объекта.
  3. Повторный захват ресурса запрещен. Если такая попытка происходит, выбрасывается исключение.
  4. Освобождение ресурса происходит только в деструкторе.
  5. Копирование запрещено.

Это «почти» RAII, единственное отличие — это возможность формального разделения операции создания объекта и захвата ресурса. Такой класс может иметь перемещающий конструктор, но не оператор перемещающего присваивания, иначе нарушится условие п. 3. Это упрощает хранение объектов в стандартных контейнерах. Несмотря на некоторую «недоделанность», данный вариант достаточно практичен.



6.2.3. Повышение уровня косвенности


Другой подход к расширению жизненного цикла ресурса — это повышение уровня косвенности. В этом случае сам объект RAII рассматривается как ресурс, а указатель на него будет дескриптором ресурса. Захват ресурса сводится к созданию объекта в динамической памяти, а освобождение к его удалению. В качестве класса, управляющим таким ресурсом, можно использовать один из интеллектуальных указателей стандартной библиотеки или аналогичный по функционалу класс (подобные классы называют классами-дескрипторами). Стратегия копирования-владения определяется интеллектуальным указателем или легко реализуется (для класса-дескриптора). Этот способ значительно проще описанного в разделе 6.2.1, единственный недостаток заключается в более интенсивном использовании динамической памяти.



6.3. Совместное владение


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



7. Итоги


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


Существует 4 основные стратегии копирования-владения:


  1. Стратегия запрета копирования.
  2. Стратегия исключительного владения.
  3. Стратегия глубокого копирования.
  4. Стратегия совместного владения.

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


Определение перемещающего конструктора и оператора перемещающего присваивания позволяет оптимизировать работу с классами, использующими стратегию глубокого копирования. Для классов, использующих стратегию запрета копирования, это позволяет расширить стратегию копирования-владения, реализовать более гибкую схему управления жизненным циклом ресурса, упростить размещение объектов в контейнерах.


При проектировании класса-владельца ресурса можно рекомендовать следующую последовательность действий. Начинать надо с запрета копирования. Если компиляция выявляет необходимость копирования и простыми средствами этого не удается избежать, то надо попробовать создавать объекты в динамической памяти и использовать интеллектуальные указатели для управления их временем жизни (см. раздел 6.2.3). Если такой вариант не устраивает, то придется реализовать семантику перемещения (см. раздел 6.2.1). Одним из основных потребителей копирования являются контейнеры стандартной библиотеки, и реализация семантики перемещения снимает практически все ограничения по их использованию. Как было сказано выше, самостоятельной реализации стратегии глубокого копирования лучше избегать, реальная потребность в ней возникает редко. Самостоятельной реализации стратегии разделяемого владения также лучше избегать, вместо этого следует использовать интеллектуальный указатель std::shared_ptr<>.



Приложения



Приложение А. Rvalue-ссылки


Rvalue-ссылки это разновидность обычных C++ ссылок, отличие состоит в правилах инициализации и правилах разрешения перегрузок функций, имеющих параметры типа rvalue-ссылка. Тип rvalue-ссылки для типа T обозначаются через T&&.


Для примеров будем использовать класс:


class Int
{
    int m_Value;
public:
    Int(int val) : m_Value(val) {}
    int Get() const { return m_Value; }
    void Set(int val) { m_Value = val; }
};

Как и обычные ссылки, rvalue-ссылки необходимо инициализировать.


Int&& r0; // error C2530: 'r0' : references must be initialized

Первым отличием rvalue-ссылок от обычных С++ ссылок заключается в том, что их нельзя инициализировать с помощью lvalue. Пример:


Int i(7);
Int&& r1 = i; // error C2440: 'initializing' : cannot convert from 'Int' to 'Int &&'

Для корректной инициализации необходимо использовать rvalue:


Int&& r2 = Int(42); // OK
Int&& r3 = 5;       // OK

или lvalue должно быть явно приведено к типу rvalue-ссылки:


Int&& r4 = static_cast<Int&&>(i); // OK

Вместо оператора приведения к типу rvalue-ссылки обычно используется функция (точнее шаблон функции) std::move(), делающая то же самое (заголовочный файл <utility>).


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


int&& r5 = 2 * 2; // OK
int& r6 = 2 * 2;  // error

После инициализации rvalue-ссылки можно использовать как обычные ссылки.


Int&& r = 7;
std::cout << r.Get() << '\n'; // Вывод: 7
r.Set(19);
std::cout << r.Get() << '\n'; // Вывод: 19

Rvalue-ссылки неявно приводятся к обычным ссылкам.


Int&& r = 5;
Int& x = r;           // OK
const Int& cx = r;    // OK

Rvalue-ссылки редко используются как самостоятельные переменные, обычно они используются как параметры функций. В соответствии с правилами инициализации, если функция имеет параметры типа rvalue-ссылок, то ее можно вызвать только для rvalue аргументов.


void Foo(Int&&);

Int i(7);
Foo(i);            // error, lvalue аргумент
Foo(std::move(i)); // OK
Foo(Int(4));       // OK
Foo(5);            // OK

Если имеются несколько перегруженных функций, то при разрешении перегрузки для rvalue аргумента версия с параметром типа rvalue-ссылка имеет приоритет над версией с параметром типа обычная ссылка или обычная ссылка на константу, хотя последние и могут быть допустимыми вариантами. И это правило является второй ключевой особенностью rvalue-ссылок.


Функция с параметром, передаваемым по значению, и перегруженная версия, имеющая параметр типа rvalue-ссылка, будут неразрешимы (ambiguous) для rvalue аргументов.


Для примера рассмотрим перегруженные функции


void Foo(Int&&);
void Foo(const Int&);

и несколько вариантов их вызова


Int i(7);
Foo(i);            // Foo(const Int&)
Foo(std::move(i)); // Foo(Int&&)
Foo(Int(6));       // Foo(Int&&)
Foo(9);            // Foo(Int&&)

Следует обратить внимание на один важный момент: именованная rvalue-ссылка сама по себе является lvalue.


Int&& r = 7;
Foo(r);            // Foo(const Int&)
Foo(std::move(r)); // Foo(Int&&)

Это надо учитывать при определении функций, имеющих параметры типа rvalue-ссылка, такие параметры являются lvalue и могут потребовать использования std::move(). См. пример перемещающего конструктора и оператора перемещающего присваивания в разделе 2.4.


Еще одно нововведение С++11, связанное с rvalue-ссылками — это ссылочные квалификаторы для нестатических функций-членов. Они позволяют перегружать по типу (lvalue/rvalue) скрытого параметра this.


class X
{
public:
    X();
    void DoIt() &;  // this указывает на lvalue
    void DoIt() &&; // this указывает на rvalue
// ...
};

X x;
x.DoIt();   // DoIt() &
X().DoIt(); // DoIt() &&


Приложение Б. Семантика перемещения


Для классов, владеющих ресурсом типа буфера памяти и использующих стратегию глубокого копирования ресурса (std::string, std::vector<>, etc.) актуальна проблема предотвращения создания временных копий ресурса. Один из самых эффективных способов решения этой проблемы — это реализация семантики перемещения. Для этого определяются перемещающий конструктор, имеющий параметр типа rvalue-ссылка на источник и оператор перемещающего присваивания с таким же параметром. При их реализации данные, включая дескриптор ресурса, копируются из объекта-источника в целевой объект, и дескриптор ресурса объекта-источника обнуляется, копирование ресурса не происходит. В соответствии с описанным выше правилом перегрузки, в случае, когда класс имеет копирующий конструктор и перемещающий, то перемещающий будет использован для инициализации с помощью rvalue, а копирующий для инициализации с помощью lvalue. Если класс имеет только перемещающий конструктор, то объект можно инициализировать только с помощью rvalue. Аналогично работает оператор присваивания. Семантика перемещения также используется при возврате из функции значения, созданного локально (в том числе и lvalue), если при этом не было применено RVO.



Список литературы


Список

[Alexandrescu]
Александреску, Андрей. Современное проектирование на C++.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2002.


[Guntheroth]
Гантерот, Курт. Оптимизация программ на C++. Проверенные методы для повышения производительности.: Пер. с англ. — СПб.: ООО «Альфа-книга», 2017.


[Josuttis]
Джосаттис, Николаи М. Стандартная библиотека C++: справочное руководство, 2-е изд.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2014.


[Dewhurst1]
Дьюхерст, Стивен С. C++. Священные знания, 2-е изд.: Пер. с англ. — СПб.: Символ-Плюс, 2013.


[Dewhurst2]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.


[Meyers1]
Мейерс, Скотт. Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов.: Пер. с англ. — М.: ДМК Пресс, 2000.


[Meyers2]
Мейерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.


[Meyers3]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендации по использованию C++11 и C ++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.


[Sutter]
Саттер, Герб. Решение сложных задач на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.


[Sutter/Alexandrescu]
Саттер, Герб. Александреску, Андрей. Стандарты программирования на С++.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2015.


[Schildt]
Шилдт, Герберт. Искусство программирования на C++.: Пер. с англ. — СПб.: БХВ-Петербург, 2005.


[Alger]
Элджер, Джефф. C++: библиотека программиста.: Пер. с англ. — СПб.: ЗАО «Издательство «Питер», 1999.




Комментарии 83

    +4
    Сухая статья, мало примеров и пояснений. Понятно будет разве что тем, кто и так всё это знает.
      +1
      Спасибо! Да, статья рассчитана на опытного программиста. Стиль конспективный, иначе объем стал бы чрезмерно большим. Моя задача была дать опытному программисту варианты выбора при проектировании классов, управляющих ресурсами, описать некоторые тонкости и подводные камни.
        0
        Огромное спасибо за статью! Именно такой стиль изложения и был мне необходим ибо знания есть, но вот поделиться ими тяжело без подобного конспекта, а отсылать читать 100500 книжек и заметок в 100500 блогах бесполезно. Привести собственные заметки к единому и цельному виду тоже не получилось… А теперь и не надо! Спасибо!
          0
          Спасибо!
          +3
          Я не являюсь опытным программистом на C++, но сказал бы, что статья содержит совсем базовые вещи.
            0
            опытным программистам тоже частенько приходится освежать базу. Часто много нового узнаешь.
        +1
        class X
        {
        public:
            X(const X&) = delete;
            X& operator=(const X&) = delete;
            X(X&& src) noexcept;
            X& operator=(X&& src) noexcept;
        // ...
        };


        Запрещать копирование не нужно, т.к. при наличии перемещающих конструктора и оператора присвоения компилятор не станет генерировать их копирующие аналоги.
          +2

          Это как раз правило хорошего стиля: "Если определяешь или =delete любую операцию по умолчанию, то определи или =delete их всех".

            0
            Учитывая, насколько часто меняются правила при смене стандарта, то да, имеет смысл так делать.

            Вообще, правило хорошего тона — это не использовать пользовательские конструкторы копирования и перемещения вообще. В этом случае компилятор их сгенерирует автоматически. Определение этих конструкторов явным образом нужно разве что при разработке библиотек базовых классов.
              0
              Это мнение Саттера, как частного лица. Если бы это было мнение комитета по стандартизации, это было бы в стандарте. Мотивировка — откровенно слабая:
              So as soon as any of the special functions is declared, the others should all be declared to avoid unwanted effects like turning all potential moves into more expensive copies, or making a class move-only.

              Т.е. запрещайте, чтобы избежать неких «нежелательных эффектов». А никаких эффектов и без вмешательства кодера не будет, поведение компилятора в этом случае выглядит вполне разумным. Ну и традиционное, как пьяный дед мороз под новый год, «explicit is better than implicit»:
              Defining only the move operations or only the copy operations would have the same effect here, but stating the intent explicitly for each special member makes it more obvious to the reader.


              P.S. лично я предпочитаю не загаживать исходный код вещами, ничего не дающими ни кодеру, ни компилятору
                +2
                Если бы это было мнение комитета по стандартизации, это было бы в стандарте.

                Стандарт — не самое релевантное место для best practices.


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


                В итоге имеем то, что имеем.

              0
              Спасибо, не обратил внимания! Но при кодировании я придерживаюсь правила: как можно меньше использовать всяких правил по умолчанию, так как они снижают читаемость кода и иногда довольно запутанны, что усугубляет ситуацию. Так, что я почти всегда использую конструкции "=default" и "=delete", даже если их можно опустить.
                +1

                Или станет? А я точно правильно эти правила запомнил? А пришедший завтра мид про них помнит/знает?


                Присоединяюсь к тем, кто считает, что их лучше писать явно.

                  0
                  Я сейчас готовлю статью, где (в том числе) обсуждается потенциальная опасность ситуации, когда функции-члены генерируются компилятором. Про проблемы перемещения, генерируемого компилятором, подробно пишет Скотт Мейерс. Я горячий сторонник все делать явно.
                +2
                Честно говоря, при всем уважении к объёму написанного, выглядит просто как компиляция информации из всех возможных источников, или как какая-то лекция по C+ в институте.
                Все это и так можно найти где угодно, и про RAII, и про move-rvalue. Не очень ясна цель поста — было б интереснее увидеть какие-то особенности или малоизвестные детали копирования/перемещения: например, RVO/NRVO, enable_shared_from_this, вопросы многопоточности shared_ptr (хотя такая статья точно здесь есть)
                  +1
                  Компиляции тоже нужны, тем более эта выполнена качественно. Мне бы эта статья лет этак 8 назад весьма бы подсократила мой (ныне оставленный) путь C++ программиста
                    0
                    Спасибо! В действительности самая первая версия этой статьи где-то 8 лет назад и появилась. Но пришел С++11 с его семантикой перемещения и многое пришлось пересмотреть, эта моя статья несомненно рекордсмен по продолжительности написания.
                    0
                    Спасибо! Искать «научную новизну» в текстах по программированию, мне кажется, не совсем правильно. А рассказ про RVO, RAII и даже семантику перемещения это не цель, а просто необходимый фон для освещения главной цели статьи: рассказать как правильно проектировать классы, управляющие ресурсами.
                      +1
                      было б интереснее увидеть какие-то особенности или малоизвестные детали копирования/перемещения

                      Aliasing shared_ptr, хехе. Восьмой конструктор здесь.

                      0

                      Труд похвальный, но сам Страуструп уже над таким работает более масштабно: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#main


                      Не совсем вижу смысл в "конкуренции" без фактического предложения альтернативы. Может есть смысл заняться переводом?

                        0
                        Спасибо! Я подозревал, что могу быть не оригинальным, но эта статья выстрадана, я с ней возился несколько лет. А Страуструпа обязательно посмотрю.
                        0
                        x.DoIt();   // DoIt() &
                        X().DoIt(); // DoIt() &&

                        Не зря дочитал до конца. Спасибо!
                          0
                          Мне концепция RAII не очень приятна. Её можно эффективно использовать для совсем простых сущностей, но для сложных лучше использование методов Create и Destroy, а не конструкторов и деструкторов. Аргументация:

                          1. В конструкторах и деструкторах нельзя (на самом деле можно, но в большинстве случаев это будет выстрелом в ногу) вызывать виртуальные методы. А иногда очень хочется это делать.

                          2. При конструировании объекта единственным способом сообщить о неудаче является исключение. А это не всегда удобный механизм (про производительность вообще молчу).

                          3. Из деструкторов вообще нельзя бросать исключения. Если что-то пошло не так, то программе придётся упасть, тогда как при неудачном вызове Destroy есть шанс отработать корректно.

                          4. Конструирование объекта может занимать длительное время, например, если объект — готовое сетевое соединение с базой данных. Если вы хотите передать код с синхронного на асинхронный, то в случае RAII вам придётся его полностью переписывать.

                          Резюме: если создание и разрушение объекта всегда производится без ошибок, а ошибка является фатальной, то используйте RAII. Если ошибка при создании объекта является штатной ситуацией, то RAII не нужен.
                            +1
                            Использование фабрик и RAII — вещи не конкурирующие, а дополняющие друг друга.

                            Ну и позволю себе чуток уточнить по вашим пунктам)

                            1. На самом деле нельзя) Если вы хотите вызвать из конструктора/деструктора виртуальный метод, то вы хотите странного.

                            2. Альтернатива — предусмотреть неинициализированное состояние объекта и перед использованием проверять, прям как в C. Исключение — удобный механизм, но производительность — соглашусь, хреновая. Но на то оно и исключение, что это именно что исключительная ситуация. А кинете вы исключение из конструктора или из фабрики — не суть важно, в любом случае все недоинициализированное придется так или иначе подчистить.

                            3. Полностью согласен, если Destroy вызывается не из деструктора какого-нибудь объекта в каком-нибудь фреймворке)

                            4. Если объект — готовое сетевое соединение, то он собственно уже готов, его осталось обернуть в RAII. Переделать код с синхронного на асинхронный — это все равно все к чертям переписать, и RAII там тоже нужен, но по-другому)

                            Как-то так)
                              0
                              И таки тем самым странным можно назвать Curiously recurring template pattern (CRTP). Но да, так или иначе вызывать CRTP-методы придется после конструирования наследников.
                                +1
                                1. На самом деле нельзя) Если вы хотите вызвать из конструктора/деструктора виртуальный метод, то вы хотите странного.
                                Оконная библиотека. У вас есть абстрактный класс, который умеет группировать объекты и его многочисленные потомки — диалоговые окна, тулбары и прочее. Вам в конструктуре было бы неплохо узнать — а какой, собственно, будет размер «внутренностей» у вашего окна (тулбар и обычный диалог тут устроены сильно по разному, как вы понимаете). В Turbo Vision для Turbo Pascal — это сделано естественным образом, через виртуальные функции. В Tubro Vision для С++ — там костыль. Можно выбрать из несколькоих — но это всё равно будет костыль.

                                Хуже того, ради того, чтобы предотвратить это «преступление» инициализация каждого объекта делает бессмысленные действия — независимо от того, нужно вам это или нет. Это в языке, который постулирует себя как «платите только за то, что используете», да.

                                Да и вообще этот запрет, якобы не позволяющий «сделать себе плохо», смотрится дико посреди языка, представляющего собой большую коллекцию ногострелов разных сортов и размеров.

                                Что же касается конструкторов-деструкторов, то там много разного бывает, но это к языку имеет мало отношения, это просто общая проблема: часто понять — что делать, когда произошла обшибка тяжело из-за специфики области применения. Тут RAII может как помогать, так и мешать.

                                Особенно тяжело, действительно, обрабатывать случаи, когда «закрытие ресурса» может сорваться и, после этого, это можно как-то купировать…
                                  –1
                                  В с++ есть очень важная гарантия — методы класса не могут быть вызваны перед конструктором и после деструктора. Виртуальные методы в конструкторе/деструкторе родителя эту гарантию нарушает. И я, например, хочу эту гарантию гораздо больше, чем решить вашу конкретную проблему чуть более элегантным (как вам кажется) способом.
                                    +2
                                    В с++ есть очень важная гарантия — методы класса не могут быть вызваны перед конструктором и после деструктора.

                                    Нету таких гарантий. Вот вам пример.

                                      +1
                                      В с++ есть очень важная гарантия — методы класса не могут быть вызваны перед конструктором и после деструктора.
                                      Во-первых все ваши гарантии выеденного яйца не стоят, если в программе есть UB (это вам уже показали). Я если вы всегда пишете без ошибок — то нафига вам какие-либо гарантии.

                                      Во-вторых если хотите без UB — то это тоже бывает. Ну или вот так.

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

                                      Виртуальные методы в конструкторе/деструкторе родителя эту гарантию нарушает.
                                      А также их нарушает куча других конструкций. И? Почему мы вот один конкретный способ стерльбы по ногам закрыли (причём закрыли путём усложенения компилятора и рантайма, а не просто как-нибудь), и при этом кучу других методов — оставили?

                                      И я, например, хочу эту гарантию гораздо больше, чем решить вашу конкретную проблему чуть более элегантным (как вам кажется) способом.
                                      Он не самый элегантный. Он самый естественный. И во всех языках, которые не прилагали специалиных усилий (C#/Java, Object Pascal, далее везде) он работает. А вот в C++ — его закрыли. Ради каких-то странных гарантий, которые всё равно можно нарушить!

                                      Вот в каком-нибудь языке типа Java (без Undefined Behavior, шаг влево, шаг право — попытка к бегству) это смотрелось бы допустимо. Но вот конкретно в C++ — это смотрится, по меньше мере, уродливо.
                                        0
                                        Хм, да, спасибо, я действительно поспешил (да ещё и сформулировал коряво).

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

                                        Кстати, вы похоже смотрели на другие языки. А как там решают данную проблему?
                                          +1
                                          Однако вы не согласны, что это некие вырожденные примеры?
                                          Да, конечно. Но вырожденными они являются только потому что виртуальные функции там вызывать всё равно нельзя.

                                          А вот например ловить ошибку того, что я использую в своей функции переменную, у которой не сработал конструктор — гораздо больший гемор.
                                          Не больший гемор, чем когда вы используете default initialization и обнаруживаете, что у вас в переменной мусор. MSAN точно так же смог бы это отловить.

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

                                          Кстати, вы похоже смотрели на другие языки. А как там решают данную проблему?
                                          Никак не решают. Потому что это — не проблема. «Конечный» конструктор (не вызванный из других) прописывает vtable — и всё, объект «готов к труду и обороне». До того, как сработает первая строчка пользовательского кода. В Java для безопасности все остальные поля обнуляются.
                                            0
                                            «Конечный» конструктор (не вызванный из других) прописывает vtable

                                            А как конструктор узнает, что он конечный?


                                            Скорее указатель на vtable прописывает вызывающий код

                                              +1
                                              У разных конструкторов имена разные. Можете посмотреть тут:
                                              ::= C1 # complete object constructor
                                              ::= C2 # base object constructor
                                              ::= C3 # complete object allocating constructor

                                              Может и вызывающий код прописывать — это от реализации зависит.

                                              Главное, что текущее поведение — это в чистом виде усложнение и замедление. Дающее весьма мало преимуществ. Я, во-всяком случае ни разу не видел применения этой «фичи» на практике.

                                              А от невозможности использовать виртуальные функции в конструкторе страдал часто.
                                              0
                                              Нет. Инициализация по-умолчанию и отсутствие инициализации — принципиально разные вещи. Вот пример.
                                              При конструировании Base вызывается foo, которая в свою очередь (если бы вызывалась foo из Derived) вызывает функцию класса A. При конструктор A ещё не был вызван. И как бы вы ловили такое? Тем более что вызов foo вполне мог бы появится внезапно для автора Derived.
                                              Заголовок спойлера
                                              struct A{
                                                  A(){
                                                      // some important work
                                                  }
                                                  void do_work(){
                                                      // need internal initialized vars
                                                  }
                                              };
                                               
                                              struct Base{
                                                  Base(){
                                                      foo();
                                                  }
                                                  virtual void foo() { }
                                              };
                                               
                                              struct Derived : Base{
                                                  Derived() = default;
                                                  virtual void foo() override {
                                                      a.do_work();
                                                  }
                                              private:
                                                  A a;
                                              };
                                               
                                               
                                              int main(){
                                                  Derived derived;
                                              }
                                              


                                            0

                                            UB там тоже не обязательно, надо отметить.

                                              0
                                              В примере @ a1ien_n3t оно обязательно. E->T интерпретируется как (*E).T в отсутсвии перекрытого operator->, а разименовывать nullptr нельзя. Если operator-> то там другие правила, но он, в свою очередь, не может быть статическим, так что UB случится всё равно.

                                              Как обычно — что и как сделает программа встретившись с UB никто не знает.
                                                0
                                                А, в том случае — да. Я про то, что дёргать виртуальные функции из конструкторов можно и без всего этого.
                                                  0
                                                  То, что из конструкторов можно дёргать методы — это не фокус. Фокус в том, что в C++ есть конструкции, позволяющие вызывать методы у ещё не сконструированного объекта. У которого конструктор ещё не вызвался. И там даже подробно специфицировано — как это работает.
                                            0

                                            Так они и не будут там вызваны. Если у вас есть struct Derived : Base, то пока выполняется конструктор Base, объект ведёт себя как Base, включая вызов виртуальных функций не ниже по иерархии, чем Base.


                                            То есть, вызывать вы можете всё что угодно (включая чистые вирутальные функции), просто они не дёрнутся из Derived (что для чистых виртуальных немного больно).

                                            0
                                            Вам в конструктуре было бы неплохо узнать — а какой, собственно, будет размер «внутренностей» у вашего окна


                                            я может быть туплю, но мне показалось, что вы хотите вызвать в конструкторе виртуальные методы объектов-агрегатов. В этом случае — никаких ограничений. А вот вызов виртуального метода самого объекта из конструктора противоречит просто здравому смыслу, так как предполагает выполнение кода потомка до вызова его конструктора
                                            0
                                            1. Язык такую возможность позволяет — значит, можно. Пример: виртуальный метод tostring() и логирование, которое этот метод использует. Хочется использовать логирование и в конструкторе, и в деструкторе.

                                            2. В таком случае все равно придётся выделять память под объект, плюс непонятно, как передавать код ошибки. Одним из полей объекта — неправильно. Пара (ссылка на объект, код ошибки), возвращаемая фабрикой, будет более логичным решением.

                                            4. Я имел в виду, что на входе — параметры подключения, на выходе — установленное соединение, т.е. логика создания объекта очень сложная. В том же C# для переписывания синхронного кода в асинхронный требуется минимум телодвижений. Даже автоматические средства существуют.
                                              0
                                              1. Язык такую возможность позволяет — значит, можно. Пример: виртуальный метод tostring() и логирование, которое этот метод использует. Хочется использовать логирование и в конструкторе, и в деструкторе.
                                              Вот как раз логирование приводится как пример того, для чего всё это безумие нужно. Ибо так, как это реализовано в C++ — внутри конструктора A вы можете использовать логирование, но оно ничего не будет знать о том, что этому объекту суждено в будущем стать объектом B. И в деструкторе — тоже. вот так, типа безопасно.

                                              Вот только на практике мне этим не пришлось воспользоваться ни разу, а вот с ошибками из-за «странного» поведения виртуальных функций — я регулярно сталкиваюсь.

                                              2. Пара (ссылка на объект, код ошибки), возвращаемая фабрикой, будет более логичным решением.
                                              А что будет с объектом, которые не до конца сконструирован? Вызывать для него деструктор или нет? А если что-то удалось, что-то нет?

                                              Это придётся очень сильно язык менять. Сейчас все эти краевые случае решаются как частный случай обработки исключения.
                                                0
                                                А что будет с объектом, которые не до конца сконструирован? Вызывать для него деструктор или нет? А если что-то удалось, что-то нет?

                                                Предполагается, что объект либо конструируется полностью, либо не создаётся вообще. Очистка ресурсов в случае ошибки создания объекта — забота фабрики.

                                                  0
                                                  Ну хорошо — пусть у вас объект наследовался от двух других. И в одном конструктор отработал, а в другом нет. В какое место вы тут вставите фабрику, которая будет это всё разруливать?

                                                  Нет, я верю что это всё можно разрулить. Но это приведёт к созданию сильно другого языка — мало похожего на современный C++.
                                                    0
                                                    Я составлю логику так, чтобы конструкторы всегда выполнялись успешно, т.е. конструктор будет просто передачей состояния от фабрики в объект.
                                                  0
                                                  C++ — внутри конструктора A вы можете использовать логирование, но оно ничего не будет знать о том, что этому объекту суждено в будущем стать объектом B. И в деструкторе — тоже. вот так, типа безопасно.

                                                  А как иначе то? Возьмем пример. Если бы A знал что ему суждено стать и перестать быть B, в A::foo будет UB — обращение к неинициализированному/удаленному объекту. А теперь представьте, что вы хотите отнаследоваться от класса, который в виртуальном деструкторе зовет несколько виртуальных же методов. Может быть всё-таки лучше как есть?
                                                    0
                                                    Может быть всё-таки лучше как есть?
                                                    Как есть — это как? Примерно так: берём простенькую программу, делаем небольшой рефакторинг… трах, бах, расчленёнка, кишки наружу.

                                                    Великолепный подход, ящитаю.

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

                                                    Подход C++ успешно сочетает недостатки обоих подходов.
                                                  +1
                                                  Пара (ссылка на объект, код ошибки), возвращаемая фабрикой, будет более логичным решением.

                                                  У вас goрячка, примите тип-сумму.

                                                    0
                                                    В том же C# для переписывания синхронного кода в асинхронный требуется минимум телодвижений. Даже автоматические средства существуют.

                                                    вот это для меня — новость. Пойду гуглить) У нас (сейчас я на scala) хоть и параллелится и асинхронится все влет, но автоматических средств нема
                                                  +1
                                                  Альтернатива — предусмотреть неинициализированное состояние объекта и перед использованием проверять, прям как в C.

                                                  На самом деле альтернатива — класс Foo вообще без публичных конструкторов (кроме, возможно, move), но со статической функцией типа


                                                  static Either<ErrorType, Foo> makeFoo(All, The, Args, You, Need);
                                                    0
                                                    Проблема в том, что как только вы захотите от этого класса унаследоваться вам всё равно придётся иметь дело с конструкторами.

                                                    По большому счёту хороший решений в C++ ровно два:
                                                    1. Смириться с исключениями и делать так, как разработчики предусмотрели (хотя всё равно неясно что делать с деструкторами).
                                                    2. Отказаться от конструкторов и деструкторов и иметь методы Init/Destroy.
                                                      +1
                                                      Что-то мне подсказывает, что наследоваться от настолько нетривиальных RAII-классов — не очень хорошая идея.
                                                        0
                                                        Вот хотите вы имитировать простой человеческий with из python'а. Для файлов. И нужно вам как-то сообщить программе, что файлик закрыть не удалось (close(2) вернулся с ошибкой — и да, это реально происходит и да, это-таки ошибка которую аккуратно написанные программы, например, emacs обрабатывают).

                                                        Ваши действия?
                                                          0

                                                          Не делать with. Там же закрытие ресурса неявное, а оно у меня тут самое что ни на есть явное.


                                                          У меня нет хорошего решения на C++, которое бы не дало вам забыть вызвать Close (или не забыть сделать обработку ошибок от самого with). У меня есть хорошее решение на языках с линейными типами (там всё очевидно), и у меня есть хорошее решение на языках с Rank-2 polymorphism (классический финт с тегированием фантомной forall-переменной).


                                                          Но я, правда, немного думал, надо ещё подумать.

                                                            +1
                                                            Там же закрытие ресурса неявное, а оно у меня тут самое что ни на есть явное.
                                                            Вопрос не в «явное/неявное». Вопрос в том, чтобы случайно не забыть закрыть и при этом обработать ошибки.

                                                            А то рассказы про то, что finally не нужен, потому что есть RAII — есть, а объяснений как решить с помощью этого RAII простейшую задачу — нет. Есть много громоздких и некрасивых решений, а хорошего — я не знаю.
                                                        0
                                                        Композиция — наше всё.
                                                      0
                                                      не стоит из плюсов делать хаскель) в плюсах для исключительных ситуаций используются исключения.
                                                        +1

                                                        Если вы ожидаете ошибку при создании объекта, то это не исключительная ситуация.


                                                        В хаскеле-то я тоже исключения кидаю, error там всякие, когда происходит адок уровня совсем нарушенных инвариантов или чего такого.

                                                          0
                                                          Если вы ожидаете ошибку при создании объекта, то это не исключительная ситуация.
                                                          От языка зависит. В том же python итератор имеет один метод next и кидает исключение, если всё закончилось.

                                                          Но ему можно: там на производительность забили большой и толстый болт, так что не проблема. В C++ хотелось бы обойтись как-нибудь.
                                                            0
                                                            да, я чуток накосячил в терминологии. Исключительная ситуация — когда ничего другого не остается, кроме как форснуть ошибку выше, вплоть до падения программы. Ошибка — это то, что обрабатываем здесь и сейчас. Первое — подмножество второго. Я имел в виду, что для обработки ошибок в плюсах принято использовать исключения. Но тут еще нужно более развернуто определить, что же такое ошибка) Exceptional-based logic тоже стоит избегать, конечно же.
                                                              +1
                                                              Исключительная ситуация — это такой класс ошибок, который не может быть обработан на текущем уровне абстракции.
                                                                0
                                                                Не соглашусь. Невозможность записать в файл на уровне функции, пишущей в файл — это абсолютно нормально и ожидаемо.

                                                                Невозможность записать в файл на уровне веб-инсталлятора — невосстановимая ошибка, можно показать соответствующее сообщение и потом счастливо закрыться.
                                                                  0
                                                                  И чем это отличается от того, что я написал выше? :)

                                                                  Не получилось записать в файл в функции записи в файл — сообщить об ошибке наверх.
                                                                  Выше попробовали как-то выкрутиться из ситуации (повтор записи, предложить выбрать другой файл для записи и т.п.), не получилось — вывалить ошибку уровню выше.
                                                                  Ещё выше подчистили ресурсы, зафлушили другие файлы, записали лог, отвалились.

                                                                  На разных уровнях одна и та же ошибка может иметь различную семантику и стратегия восстановления после сбоя может быть разной.
                                                                    0
                                                                    Так я предлагаю в функции записи в файл не экзепшон кидать, а возвращать Either<Error, Unit>. Потому что для неё это ожидаемо.

                                                                    А на уровне выше в тупом и нетребовательном к стабильности и отказоустойчивости приложении if (!IsRight (result)) throw error;

                                                                    Так что отличается, эм, всем? :)
                                                                      0
                                                                      По опыту скажу, что невозможность записи в файл в подавляющем большинстве случаев является ошибкой программиста (неправильно указан путь) и, соответственно, исключительной ситуацией. Реже — переполнением диска, отсутствием прав доступа и т.д., что впрочем тоже ситуации исключительные.

                                                                      На мой взгляд, лучше иметь перегрузки с/без исключений. Просто потому, что исключения часто удобнее, но не всегда подходят
                                                                        0
                                                                        Отличается не подход, а реализация.
                                                                        Не можешь обработать — сообщи наверх. А исключение это, код ошибки, хитрый код ошибки, монада или что-то ещё — дело десятое.

                                                                        Описанный подход с Either обладает тем недостатком, что заставляет либо тащить наружу все возможные детали всех возможных ошибок, усложняя обработку ошибок, либо, наоборот, скрывать причины ошибок. Меняется реализация с новым набором ошибок — боль либо неизвестность.
                                                                          0
                                                                          Принципиальная разница в том, что механизм исключений предполагает невозможность их игнорирования. Не перехватил исключение — оно выбросится наверх. Проигнорировал код возврата — ничего не случится, но логика работы программы может быть нарушена.
                                                                            0
                                                                            Диалектика локальной и нелокальной стратегии обработки ошибок. Марксистско-Ленинская философия программизма. Т. 3, стр. 14 ))))
                                                                              0
                                                                              catch (...) {} коде уже запретили?
                                                                                0
                                                                                В таком случае никакого смысла в использовании исключений вообще нет.
                                                                                  0

                                                                                  Технически есть — если у вас нет подходящего catch вплоть до main, то раскрутка стека не обязательна, ЕМНИП.

                                                                                  0

                                                                                  Да и с Either никто не мешает делать fromRight и подобное. Но это надо делать явно.

                                                                                0
                                                                                Ну, да, это проблема реализации. Придётся вытаскивать всё в темплейты в хедеры и всё такое.

                                                                                Но явно упоминать дополнительные типы нижележащих функций вам в вашем коде не нужно, вы просто добавляете тип вашей ошибки в список типов.
                                                              0
                                                              Про ограниченность RAII я пишу прямым текстом, раздел 6 как раз и посвящен тому, как можно преодолеть эту ограниченность. Проблемы традиционного протокола создания/удаления объекта через конструктор и деструктор и методы их решения не обсуждал. Несомненно, что это интересная тема, но статья и так большая, пришлось себя ограничивать.
                                                              +2
                                                              Функция-член Swap() определяется обычно легко: необходимо последовательно применять к базам и членам операцию обмена состояниями, если они ее поддерживают, и std::swap() в противном случае.

                                                              Если уж вы говорите про ADL, то имеет смысл упомянуть о таком паттерне, как


                                                              using std::swap;
                                                              swap(a, b);

                                                              ADL сам выберет подходящую версию функции.


                                                              RVO применяется разработчиками компиляторов достаточно давно и в настоящее время зафиксирована в стандарте C++11.

                                                              Которая при этом всё равно требует наличия accessible конструктора копирования.


                                                              А в С++17 добавили обязательное RVO, и теперь так можно возвращать классы, которые нельзя ни копировать, ни move'ать.

                                                                0
                                                                Вроде всё знал, что написано, но, когда это собрано в одном месте, то стало понятнее и логичнее.
                                                                Спасибо!
                                                                  0
                                                                  Спасибо!
                                                                • НЛО прилетело и опубликовало эту надпись здесь
                                                                    0
                                                                    Не совсем понял, о чем речь. Если можно, поподробнее.

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

                                                                  Самое читаемое