Главная » Хабрахабр » [Из песочницы] Семантика копирования и управление ресурсами в C++

[Из песочницы] Семантика копирования и управление ресурсами в C++

Основные стратегии копирования-владения
    1.   Введение
  1. Стратегия запрета копирования
    1. 1. Стратегия исключительного владения
    1. 2. Стратегия глубокого копирования
    1. 3. Стратегия совместного владения
  2. 4. 1. Стратегия глубокого копирования — проблемы и решения
    2. 2. Копирование при записи
    2. 3. Определение функции обмена состояниями для класса
    2. 4. Удаление промежуточных копий компилятором
    2. 5. Реализация семантики перемещения
    2. вставки
    2. Размещение vs. Итоги
  3. 6. Стратегия исключительного владения и семантика перемещения
  5. Возможные варианты реализации стратегии совместного владения
  4. Жизненный цикл ресурса и объекта-владельца ресурса
    6. Стратегия запрета копирования — быстрое начало
  6. Захват ресурса при инициализации
    6. 1. Расширенные варианты управления жизненным циклом ресурса
      6. 2. 1. 2. 2. Расширенный жизненный цикл ресурса
      6. Однократный захват ресурса
      6. 2. 3. 2. 3. Повышение уровня косвенности
    6. Итоги
  Приложения
    Приложение A. Совместное владение
  7. Семантика перемещения
  Список литературы
Rvalue-ссылки
    Приложение Б.

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

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

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

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

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;
// ...
};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

inline void swap(X& a, X& b) noexcept

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

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

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

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

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

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

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

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

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

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

И функцию

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

Но компиляторы умеют удалять из кода операцию копирования, объект создается непосредственно в точке вызова. При прямолинейном подходе возврат из функции Foo() реализуется через копирование экземпляра X. RVO применяется разработчиками компиляторов достаточно давно и в настоящее время зафиксирована в стандарте C++11. Это называется оптимизацией возвращаемого значения (return value optimization, RVO). Для этого желательно, чтобы функция имела одну точку возврата и тип возвращаемого выражения совпадал с типом возвращаемого значения функции. Хотя решение об 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);
}

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

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

Во многие классы добавлены перемещающий конструктор и оператор перемещающего присваивания, а также другие функции-члены, с параметрами типа rvalue-ссылка. Стандартная библиотека C++11 переработана с учетом семантики перемещения. Все это позволяет во многих случаях избегать создания временных копий. Например, 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-ссылка.

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

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

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

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

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

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

  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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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. Стратегия копирования-владения определяется интеллектуальным указателем или легко реализуется (для класса-дескриптора). 1, единственный недостаток заключается в более интенсивном использовании динамической памяти. 2.

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

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

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

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

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

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

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

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

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

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

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

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(). 4. пример перемещающего конструктора и оператора перемещающего присваивания в разделе 2.

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

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. В соответствии с описанным выше правилом перегрузки, в случае, когда класс имеет копирующий конструктор и перемещающий, то перемещающий будет использован для инициализации с помощью rvalue, а копирующий для инициализации с помощью lvalue. Семантика перемещения также используется при возврате из функции значения, созданного локально (в том числе и lvalue), если при этом не было применено RVO. Аналогично работает оператор присваивания.

Список

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

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

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

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

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

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

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

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

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

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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Разработка buck-преобразователя на STM32F334: принцип работы, расчеты, макетирование

В двух своих последних статьях я рассказал о силовом модуле и плате управления на базе микроконтроллера STM32F334R8T6, которые созданы специально для реализации систем управления силовыми преобразователями и электроприводом. Так же был рассмотрен пример DC/AC преобразователя, который являлся демонстрацией, а не ...

Simulation theory: взаимосвязь квантово-химических расчётов и Реальности

Введение О чём этот текст Если человек услышит о «симуляции реальности», то в наиболее вероятно ему в голову придут или разные научно-фантастические произведения (типа Матрицы, Темного города, или Теоремы Зеро), или компьютерные игры. В случае людей, чьи головы засорены инженерным ...