Объект, управляющий ресурсом

Кувшинов Д.Р.

2016


Общее оглавление


Основные определения

Конструктор constructor — специальная функция, определяемая классом и выполняемая при создании нового объекта класса. Конструкторов может быть много, но в процессе создания объекта обязательно вызывается какой-то из них. Имя конструктора совпадает с именем типа, для которого этот конструктор определён.

После успешного завершения конструктора объект считается полностью созданным и может использоваться. В случае прерывания работы конструктора (бросанием исключения) объект считается неполностью созданным. Тогда, если не происходит аварийный останов исполнения программы, для каждой части неполностью созданного объекта, которая была создана (поля и подобъекты базовых классов, для которых были вызваны конструкторы), автоматически вызываются деструкторы.


Деструктор destructor — специальная функция-член класса, не принимающая параметров и вызываемая в процессе уничтожения объекта. Класс может определить только один деструктор. После вызова деструктора объект считается переставшим существовать, и его использование порождает неопределённое поведение. Деструктор не освобождает память, занимаемую объектом — это делается уже после завершения деструктора.

В случае возникновения исключения в процессе работы деструктора выполнение программы завершается вызовом std::terminate() (аварийный останов исполнения программы, вызов каких-либо других деструкторов не гарантируется).

Нельзя взять адрес конструктора или деструктора.

Конструктор можно вызвать явно для того, чтобы создать объект в заданном участке памяти с помощью специального синтаксиса на основе оператора new (“placement new”):

// Вызвать конструктор по умолчанию типа T для блока памяти, на который указывает p.
template <class T>
T* construct(void *p)
{
  return new(p) T; // после T можно было бы указать параметры в скобках
}

Деструктор также можно вызвать с помощью специального синтаксиса (используя “тильду”):

// Вызвать деструктор типа T для объекта, размещённого по адресу, на который указывает p.
template <class T>
void destroy(T *p)
{
  p->~T();
}


Конструктор по умолчанию default constructor — конструктор, не принимающий параметров. Необходим, если создаются массивы объектов. Обычно генерируется компилятором автоматически, если пользователь не определил ни одного конструктора и для всех полей и базовых классов определены свои конструкторы по умолчанию.

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

Копирующий конструктор copy constructor — особый вид конструктора, создающий копию другого объекта. Принимает ссылку на объект того же класса.

Копирующий оператор присваивания copy assignment operator — перегруженный оператор присваивания, принимающий ссылку на объект того же класса. Выполняет замену содержимого объекта на копию другого объекта. Должен корректно обрабатывать ситуацию присваивания значения самому себе (a = a). Должен возвращать неконстантную ссылку на сам объект (return *this;).

struct Thing
{
  /// Конструктор по умолчанию
  Thing() { cout << "I am " << this << "!\n"; }
  
  /// Копирующий конструктор
  Thing(const Thing &other)
  {
    cout << "Thing " << this << " is a copy of ";
    cout << "thing " << &other << '\n';
  }
  
  /// Копирующий оператор присваивания
  Thing& operator=(const Thing &other)
  {
    if (this != &other)
    {
      cout << "Thing " << this << " is now a copy of ";
      cout << "thing " << &other << '\n';
    }
    return *this;
  }
  
  /// Деструктор
  ~Thing() { cout << "Thing " << this << " vanishes\n"; }
};

int main()
{
  Thing a; // вызов конструктора по умолчанию
  Thing b(a); // вызов копирующего конструктора
  Thing c = b; // вызов копирующего конструктора
  a = c; // вызов копирующего оператора присваивания
  // автоматический вызов деструкторов: c, b, a.
}

Тривиальный копирующий конструктор (и аналогично оператор присваивания) — определяется аналогично тривиальному конструктору по умолчанию, с поправкой, что требуются тривиальные конструкторы копирования (операторы присваивания) всех полей и базовых классов. Кроме того, требуется, чтобы объект не содержал полей с квалификатором volatile. Тривиальное копирование выполняется “побитно” (копирование кусков памяти) и может быть выполнено вызовом стандартной функции memcpy (т. е. не обязательно по порядку расположения полей в памяти).

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

Если тип имеет тривиальные деструктор, конструктор по умолчанию и копирующие (а также перемещающие — см. ниже) конструкторы и операторы присваивания, то он называется тривиальным.

Встроенные “скалярные” типы (числа и указатели) являются тривиальными. Однако, явный вызов “конструктора по умолчанию” скалярного типа называется псевдоконструктором и возвращает нулевое значение (это сделано для удобства написания функций-шаблонов).

int a = int(); // вызов псевдоконструктора, возвращает 0


Класс является типом со стандартным размещением standard layout type, если для него выполняются следующие условия:

Если тип является одновременно тривиальным и типом со стандартным размещением, то он называется POD-типом (“plain old data”) и его можно совместимым образом представить в виде структуры на языке C (не C++), что позволяет использовать объекты таких типов для передачи данных между библиотеками, написанными на разных языках программирования.


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

Жизненный цикл объекта:

  1. Выделение памяти под объект.
  2. Вызов конструктора (может быть пропущен для тривиальных конструкторов по умолчанию), либо инициализация полей конкретным набором значений (возможна только для простых типов без конструторов, определённых пользователем (в частности, POD-типа Point в примере ниже):
struct Point { float x, y; } p = { 10, -5 };
  1. Время жизни объекта.
  2. Вызов деструктора (может быть пропущен для тривиальных деструкторов).
  3. Освобождение памяти, выделявшейся под объект.

Для статических (глобальных) объектов:

  1. Выделение памяти происходит при загрузке исполняемого файла операционной системой.
  2. Для глобальных объектов вызов конструктора выполняется до входа в функцию main, конструкторы вызываются в порядке определения объектов в содержащей их единице трансляции. Относительный порядок вызова конструкторов объектов из разных единиц трансляции не определён. Локальные статические переменные функций конструируются при первом исполнении блока, содержащего их определение.
  3. Время жизни простирается до завершения работы программы.
  4. Вызов деструкторов производится автоматически в обратном порядке вызову конструкторов (в рамках одной единицы трансляции) после выхода из функции main.
  5. Освобождение памяти осуществляется ОС вместе со всей памятью программы.

Для автоматических (локальных) объектов:

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

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

Для объектов в динамической памяти:

  1. Выделение памяти происходит при выполнении оператора new (создание объекта).
  2. Конструктор вызывается оператором new после того, как была выделена память.
  3. Время жизни простирается до удаления объекта.
  4. Деструктор вызывается оператором delete (удаление объекта).
  5. После вызова деструктора, оператор delete возвращает память, которая была занята удалённым объектом, менеджеру динамической памяти, после чего она может быть повторно использована при очередном вызове new.

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

Для временных объектов (частный случай автоматических объектов):

  1. Выделение памяти происходит на стеке вызовов при выполнении блока или функции, содержащих выражение, порождающее временное значение.
  2. Конструктор вызывается в момент вычисления значения выражения.
  3. Время жизни простирается не далее, чем до ; или ) (если выражение находится в условии if, while и т. п.).
  4. Деструктор вызывается автоматически в момент завершения времени жизни.
  5. Освобождение памяти осуществляется после выхода из блока или функции.

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

void foo()
{
  const auto & obj = make_object(); // Функция возвращает временный объект.
  ...
  cout << obj.get_some_value(); // OK
  ...
  // При выходе из foo объект, привязанный к obj, разрушается.
}


RAII (resource acquisition is initialization — выделение ресурса есть инициализация) — принцип управления ресурсами, в котором каждый ресурс управляется конкретным объектом. При этом ресурс выделяется в момент создания объекта (конструктором) и освобождается в момент уничтожения объекта (деструктором).

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

double stream_compute(size_t bufsize)
{
  double result = 0;
  auto input = new double[bufsize];
  auto output = new double[bufsize]; // в случае исключения здесь будет утечка памяти!
  
  // ... в случае исключения здесь будет утечка памяти!
  
  delete[] output;
  delete[] input;
  return result;
}

Исправить этот код без RAII можно с помощью дополнительных конструкций try-catch (и это ещё весьма простой пример, в языке C в похожих случаях используются “каскады” goto):

double stream_compute(size_t bufsize)
{
  double result = 0;
  double *input = nullptr, *output = nullptr;
  
  try
  {
    input = new double[bufsize];
    output = new double[bufsize];
  
    // ... 
  }
  catch (...)
  {
    // дублирование кода, освобождающего ресурс:
    delete[] output;
    delete[] input;
    throw;
  }
  
  delete[] output;
  delete[] input;
  return result;
}

При использовании RAII мы задействуем класс, деструктор которого сам освободит ресурс. Например, в случае буфера в памяти таким классом может быть std::vector:

double stream_compute(size_t bufsize)
{
  double result = 0;
  vector<double> input_buf(bufsize), output_buf(bufsize);
  
  double *input = input_buf.data();
  double *output = output_buf.data();
  
  // ...
  
  return result;
}


Пример: динамический массив

В качестве примера класса, объекты которого распоряжаются некоторым ресурсом, которым нужно управлять в конструкторах, операторах присваивания и деструкторах, рассмотрим класс, реализующий удобную работу с динамическим массивом. Можно считать его упрощённым (учебным) аналогом стандартного контейнера vector.

Версия 1

Массив — указатель на первый элемент dt (“данные”) и размер (число элементов) sz.

template <class T>
class Darr
{
  T *dt;      // data
  size_t sz;  // size
  
public:
  // Узнать размер массива.
  size_t size() const { return sz; }
};

Можно добавить вспомогательную функцию data, позволяющую получить адрес самого массива непосредственно. Такая функция может быть удобна для передачи массива функциям, написанным на C.

  // Прямой доступ к хранимому массиву.
  T* data() { return dt; }
  const T* data() const { return dt; }

Для обращения к элементу массива по индексу следует перегрузить оператор [].

  T& operator[](size_t idx)
  {
    return dt[idx];
  }
  
  const T& operator[](size_t idx) const
  {
    return dt[idx];
  }

Обратите внимание, что и data, и operator[] определены в двух вариантах: с const после списка параметров и без const после списка параметров. Наличие const сообщает компилятору, что данная функция не изменяет объект, для которого вызывается (указатель this будет иметь тип const Darr<T>*). Именно const-вариант будет вызван для константного массива (например, полученного через const-ссылку).

Принято делать эту константность транзитивной: т. е. значения, возвращаемые такими функциями также не должны позволять изменять объект. Здесь это указатель на массив в случае data (константный = только для чтения, неконстантный = можно изменять элементы) и ссылка на элемент с заданным индексом в случае operator[] (константная = только для чтения, неконстантная = можно изменять элемент).

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

Использование ссылочного типа в operator[] демонстрирует одну из причин, по которым ссылки были добавлены в C++ — это простейшее решение проблемы синтаксического оформления перегрузки операторов в виде обычных функций.

Теперь определим конструкторы и деструктор.

Конструктор по умолчанию создаёт пустой массив:

  Darr(): dt(nullptr), sz(0) {}

Определение данного элементарного (но не тривиального) конструктора задействует синтаксический элемент, известный как список инициализации.

Список инициализации initialization list в конструкторе — синтаксическая конструкция, позволяющая выполнить явную передачу параметров конструкторам базовых классов и полей объекта. Эти конструкторы выполняются до выполнения собственного конструктора объекта, причём их порядок строго задан определением класса:

  1. Сначала вызываются конструкторы базовых классов в порядке их перечисления в списке базовых классов в определении класса: class X: A, B, C — сначала будет вызван конструктор A, затем конструктор B, затем — C. В процессе, если требуется, изменяются значения скрытых полей, в частности, указатель на таблицу виртуальных функций, благодаря чему объект обретает (или меняет) динамический (в смысле RTTI) тип.
  2. После них вызываются конструкторы полей объекта в порядке их перечисления в определении класса.
  3. Только после этого выполняется тело конструктора самого объекта.
  4. При уничтожении объекта деструкторы вызываются в порядке, обратном порядку вызовов конструкторов.

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

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

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

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

Без списка инициализации возникла бы проблема с использованием (в качестве базовых классов или типов полей) классов, у которых нет конструкторов по умолчанию, так как их конструкторы требуют передачи конкретных параметров, которые никак не передать из тела конструктора объекта.

Конструктор, принимающий размерный тип, создаёт массив заданного размера. Этот массив будет инициализирован конструктором по умолчанию типа T (псевдоконструкторы не вызываются). Деструктор удаляет динамический массив.

  Darr(size_t n)
    : dt(new T[n]), sz(n) {}
  ~Darr() { delete[] dt; }

Теперь рассмотрим следующий пример.

Darr<int> a(100), b = a;
// ...
// вызов деструктора b
// вызов деструктора a -- ошибка

Мы не определили копирование, а значит, компилятор его определил за нас. Как “побитовое” копирование представления (тривиальный конструктор копирования), из-за чего получается, что объекты a и b содержат одно и то же значение указателя на массив, нарушая условие управления ресурсом: ресурс должен принадлежать только одному объекту. Из-за этого нарушения деструктор a пытается повторно удалить массив, уже удалённый деструктором b, что является серьёзной ошибкой.

Правило трёх

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

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

  Darr(const T arr[], size_t n)
    : Darr(n)
  {
    for (size_t i = 0; i < n; ++i)
      dt[i] = arr[i];
  }
  
  Darr(const Darr<T> &other)
    : Darr(other.data(), other.size()) {}

Определим операцию обмена представлений двух массивов swap (которую можно выполнить эффективно путём обмена значений полей dt и sz). На основе swap и конструтора копирования определим копирующий оператор присваивания. В итоге мы избегаем повторения кода (копирование массива), что является эффективным средством борьбы с ошибками “рассинхронизации” (когда в одном месте код исправили, а в другом — аналогичном — забыли).

  void swap(Darr<T> &other)
  {
    using std::swap;
    swap(dt, other.dt);
    swap(sz, other.sz);
  }
  
  Darr<T>& operator=(const Darr<T> &other)
  {
    Darr<T>(other).swap(*this);
    return *this;
  }

Проанализируйте приведённый выше код operator=. Как он работает?


Рассмотрим следующий пример.

Darr<int> a, b = a;
// --> new int[0] ???

Увы, имеем ошибку. Чтобы программа не пыталась создать динамический массив нулевого размера следует поправить конструктор (объединим конструктор по умолчанию и конструктор массива заданного размера в одну функцию):

  Darr(size_t n = 0)
    : dt(n? new T[n]: nullptr), sz(n) {}


Что делает следующий код?

Darr<int> a = 10; // ???

По правилам C++ конструктор, принимающий один параметр и не являющийся конструктором копирования, используется как операция преобразования типа, поэтому целое число “преобразуется” в массив, становясь его (массива) размером… Так как такое поведение может быть незапланированным и приводить к неожиданностям (при молчании компилятора), следует (если нет особых соображений против этого) помечать подобные конструкторы ключевым словом explicit, которое запрещает использовать конструктор в качестве операции неявного преобразования типа.

  explicit
  Darr(size_t n = 0)
    : dt(n? new T[n]: nullptr), sz(n) {}

Теперь всё целиком.

template <class T>
class Darr
{
  T *dt;           // data
  size_t sz;  // size
  
public:
  // Деструктор.
  ~Darr() { delete[] dt; }

  // Конструкторы.
  explicit
  Darr(size_t n = 0)
    : dt(n? new T[n]: nullptr), sz(n) {}

  Darr(const T arr[], size_t n)
    : Darr(n)
  {
    for (size_t i = 0; i < n; ++i)
      dt[i] = arr[i];
  }
    
  Darr(const Darr<T> &other)
    : Darr(other.data(), other.size()) {}
    
  // Копирующее присваивание.
  Darr<T>& operator=(const Darr<T> &other)
  {
    Darr<T>(other).swap(*this);
    return *this;
  }

  // Обменять содержимое двух объектов Darr.
  void swap(Darr<T> &other)
  {
    using std::swap;
    swap(dt, other.dt);
    swap(sz, other.sz);
  }  

  // Узнать размер массива.
  size_t size() const { return sz; }
  
  // Прямой доступ к хранимому массиву.
  T* data() { return dt; }
  const T* data() const { return dt; }
  
  // Обращение к элементу по индексу
  T& operator[](size_t idx)
  {
    return dt[idx];
  }
  
  const T& operator[](size_t idx) const
  {
    return dt[idx];
  }
};

C++, HTML


Версия 2

Определим следующую функцию, порождающую массив размера n, заполненный целыми числами 0, 1, …, n–1.

Вариант 1

Darr<int> iota(int n)
{
  Darr<int> result(n);
  for (int i = 0; i < n; ++i)
    result[i] = i;
  return result; // копирование?
}

Вариант 2

Darr<int>* iota(int n)
{
  auto result = new Darr<int>(n);
  for (int i = 0; i < n; ++i)
    (*result)[i] = i;
  return result; // надо будет не забыть delete...
}

Вариант 3

Darr<int>& iota(Darr<int> &result)
{
  for (int i = 0; i < result.size(); ++i)
    result[i] = i;
  return result; // нужна отдельная переменная
}

Возвращать значения из функции обычным способом (вариант 1) удобнее и логичнее, чем городить ссылки-указатели или что-то подобное и передавать параметром (варианты 2 и 3). Однако в варианте 1 мы имеем передачу объекта, управляющего ресурсом, находящегося в локальной переменной, время жизни которого ограничено функцией, в которой он был создан… Это приводит к копированию значения через временный объект (формально это даже два копирования), что может быть затратной операцией и, как показывают варианты 2 и 3, её вполне можно было бы избежать.

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

Исторически в C возникли понятия lvalue (“l” от “left”) и rvalue (“r” от “right”), как термины для обозначения значений которые могут стоять слева от = (lvalue) или только справа от = (rvalue):

lvalue = rvalue;

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

lvalue — функция или объект в памяти (включая те, что определены как константы, т. е. формально не могут находиться слева от =).

xvalue expiring value — локальная переменная перед завершением времени жизни (т. е. управляемый ресурс разрешается “забрать”, не выполняя копирования).

prvalue pure rvalue — временные значения, возникающие в процессе вычисления выражений, включая непосредственно заданные в коде константы (литералы) и константы времени компиляции, не имеющие адреса (например, определённые с помощью enum).

rvalueprvalue или xvalue — то, что можно (или даже следует) переместить, не копируя.

glvaluelvalue или xvalue.

Классификацию можно строить также на основе наличия или отсутствия следующих двух признаков:

  1. Идентичность — наличие собственного места в памяти (уникального адреса): идентичностью обладают все glvalue, в то время как prvalue не обладают идентичностью.
  2. Перемещаемость — наличие перемещающих конструктора и оператора присваивания: rvalue должны быть перемещаемыми.

Ссылочный тип T& называется lvalue-ссылкой (“lref”). Для передачи по ссылке rvalue (позволяющей заменить копирование перемещением) введены rvalue-ссылки (“rref”), записываемые для конкретного типа T как T&&. Поэтому если конструктор или оператор присваивания принимает rref (а не lref) на объект своего типа, то он вызывается для rvalue-объектов и может забрать ресурс, не копируя. Такие конструкторы и операторы присваивания называются перемещающими moving. Для преобразования lref в rref предназначена стандартная функция move.

Данная функция упрощённо может быть определена следующим образом:

template <class T> inline
T&& move(T& obj)
{
  return static_cast<T&&>(obj);
}

Правильное определение следующее (C++14, используют утилиту remove_reference_t из <type_traits>):

template <class T> inline
constexpr remove_reference_t<T>&& move(T&& obj) noexcept
{
  return static_cast<remove_reference_t<T>&&>(obj);
}

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

Итак, “правило трёх” может быть расширено.

Правило пяти

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

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

  // Освободить память.
  void clear() { Darr<T> a; *this = a; }
  
  // Перемещающий конструктор (из временного объекта).
  Darr(Darr<T> &&other)
    : Darr()
  {
    other.swap(*this);
  }
  
  // Перемещающий оператор присваивания.
  Darr<T>& operator=(Darr<T> &&other)
  {
    if (this != &other)
    {
      swap(other);
      other.clear();
    }
    return *this;
  }

C++, HTML


Здесь вдумчивый читатель может задать вопрос: как же оператор присваивания берёт адрес other, ведь это может быть prvalue? Однако семантика передачи следующая: параметр был rvalue вне функции, которой он был передан по rvalue-ссылке. Внутри функции он считается lvalue и other имеет тип lvalue-ссылки.

Теперь можно спокойно использовать вариант 1 функции iota, зная, что при возврате result из функции копирования массива не будет, поскольку result внутри return является xvalue и происходит перемещение во временный объект (результат вызова функции), который является prvalue и при присваивании этого значения переменной также происходит перемещение вместо копирования. Перемещение сводится к передаче значения указателя и размера и зануления объекта, из которого было произведено перемещение.


Следующий пример демонстрирует класс, объекты которого управляют внешним ресурсом (“сокетами”, например сетевыми соединениями) с помощью некой библиотеки на языке C. Эти объекты нельзя копировать, так как соединение уникально, но можно перемещать:

// Часть некоторой библиотеки.
struct Socket;
// Создать объект соединения (сокет).
Socket* openSocket(const char*);
// Закрыть соединение (удалить сокет).
void closeSocket(Socket*);

// Обёртка, автоматически вызывающая closeSocket.
class Socket_handle
{
  Socket *sh;

public:
  explicit
  Socket_handle(Socket *sh = nullptr) noexcept
    : sh(sh) {}
  
  explicit
  Socket_handle(const char *name)
    : sh(openSocket(name)) {}

  ~Socket_handle()
  {
    closeSocket(sh);
  }
  
  // Копировать нельзя.
  Socket_handle(Socket_handle&) = delete;
  Socket_handle& operator=(Socket_handle&) = delete;
  
  // Перемещать можно.
  Socket_handle(Socket_handle &&other) noexcept
    : sh(other.sh)
  {
    other.sh = nullptr;
  }
  
  Socket_handle& operator=(Socket_handle &&other) noexcept
  {
    Socket_handle closer(move(other));
    swap(closer);
    return *this;
  }
  
  void swap(Socket_handle &other) noexcept
  {
    std::swap(sh, other.sh);
  }
  
  Socket* handle() noexcept { return sh; }
};


Версия 3

Правило нуля: в идеальном случае все пять функций из правила пяти:

— должны корректно определяться по умолчанию.

Для этого поля класса и базовые классы сами должны корректно реализовывать данное поведение.

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

Класс “динамический массив” (в частности, std::vector) по сути сам является чуть ли не базовым строительным блоком. Однако, есть в Стандартной библиотеке компонент ещё более примитивный. Это “умный указатель” smart pointer unique_ptr.

Уникальный указатель” unique_ptr — распоряжается временем жизни объекта или массива. Объект unique_ptr хранит “обычный” указатель, по умолчанию инициализируемый нулём. Слово “уникальный” unique здесь используется в том смысле, что только один объект unique_ptr может содержать адрес того или иного управляемого объекта. Таким образом, запрещается копирование unique_ptr (иначе нарушалась бы гарантия единственности), но разрешается перемещение (передача управления). Деструктор unique_ptr удаляет управляемый объект.

Для создания объектов unique_ptr рекомендуется использовать стандартную функцию (C++14) make_unique. Есть два варианта данной функции:

Переделаем наш класс Darr так, чтобы он использовал для управления хранимым массивом unique_ptr. Если бы мы с самого начала использовали unique_ptr, то компилятор не позволил бы нам ошибочно скопировать объект (так как копирование запрещено).

template <class T>
class Darr
{
  std::unique_ptr<T[]> dt; // data -- изменено
  size_t sz;               // size

public:
  // Деструктор по умолчанию.
  // Конструкторы.
  explicit
  Darr(size_t n = 0)
    : dt(n ? std::make_unique<T[]>(n) : nullptr), sz(n) {}

  Darr(const T arr[], size_t n)
    : Darr(n)
  {
    for (size_t i = 0; i < n; ++i)
      dt[i] = arr[i];
  }

  Darr(const Darr<T> &other)
    : Darr(other.data(), other.size()) {}

  // Перемещающий конструктор (из временного объекта) -- изменено: по умолчанию.
  Darr(Darr<T> &&other) = default;
  
  // Перемещающий оператор присваивания -- изменено: по умолчанию.
  Darr<T>& operator=(Darr<T> &&other) = default;

  // Копирующее присваивание.
  Darr<T>& operator=(const Darr<T> &other)
  {
    Darr<T>(other).swap(*this);
    return *this;
  }

  // Освободить память -- изменено.
  void clear() { dt.reset(); sz = 0; }

  // Обменять содержимое двух объектов Darr.
  void swap(Darr<T> &other)
  {
    using std::swap;
    swap(dt, other.dt);
    swap(sz, other.sz);
  }

  // Проверить на пустоту -- изменено.
  bool empty() const { return !dt; }

  // Узнать размер массива -- изменено.
  size_t size() const { return dt? sz: 0; }

  // Прямой доступ к хранимому массиву -- изменено.
  T* data() { return dt.get(); }
  const T* data() const { return dt.get(); }

  // Обращение к элементу по индексу
  T& operator[](size_t idx)
  {
    assert(idx < size());
    return dt[idx];
  }

  const T& operator[](size_t idx) const
  {
    assert(idx < size());
    return dt[idx];
  }
};

C++, HTML


Умные указатели

Умными указателямиsmart pointers называют объекты, которые, подражая поведению обычных указателей, дополняют его новыми возможностями или гарантиями. Как правило, это касается управления памятью. Обычные указатели не управляют памятью, на которую указывают — их конструкторы и деструкторы тривиальны.

unique_ptr

О unique_ptr см. выше.

Пример можно переписать на основе unique_ptr:

double stream_compute(size_t bufsize)
{
  double result = 0;
  auto input_buf = make_unique<double[]>(bufsize);
  auto output_buf = make_unique<double[]>(bufsize);
  
  double *input = input_buf.get();
  double *output = output_buf.get();
  
  // ...
  
  return result;
}

Конечно, vector предпочтительнее, если, например, требуется изменять размер хранилища, однако unique_ptr является более легковесной конструкцией (как для компилятора, так и для среды исполнения).


Несколько неожиданным может показаться тот факт, что на основе unique_ptr можно также переделать пример с сокетами. Дело в том, что unique_ptr позволяет задавать произвольный функтор, выполняющий освобождение (“удаление”) ресурса — “удалительdeleter (устоявшегося перевода данного термина на русский язык на данный момент нет).

Удалитель задаётся вторым параметром шаблона unique_ptr, по умолчанию — это std::default_delete<T>, выполняющий delete или delete[] (в зависимости от типа T). Класс unique_ptr предоставляет также конструктор, позволяющий передать конкретный объект удалителя, что имеет смысл, если удалитель содержит некое внутреннее состояние. Для Socket_handle нам понадобится свой удалитель, вызывающий функцию closeSocket:

struct Socket_close
{
  void operator()(Socket *s) const
  {
    closeSocket(s);
  }
};

Весь смысл Socket_close заключается в вызове функции closeSocket, поэтому в качестве объекта удалителя можно было бы передать саму функцию closeSocket, а в качестве параметра шаблона — тип указателя на функцию void(*)(Socket*). Однако, такой способ имеет тот недостаток, что объект unique_ptr в этом случае вынужден хранить этот указатель (удваивает размер unique_ptr). Кроме того, появляется возможность некорректной инициализации указателя на функцию-удалитель (забыть передать конструктору указатель на closeSocket), что ведёт к неопределённому поведению.

Теперь можно построить Socket_handle на основе unique_ptr:

class Socket_handle
{
  unique_ptr<Socket, Socket_close> sh;

public:
  explicit
  Socket_handle(Socket *sh = nullptr) noexcept
    : sh(sh) {}
  
  explicit
  Socket_handle(const char *name)
    : sh(openSocket(name)) {}

  void swap(Socket_handle &other) noexcept
  {
    std::swap(sh, other.sh);
  }
  
  Socket* handle() noexcept { return sh.get(); }
};


Следующий пример — простейший стек на основе связанного списка, звенья которого используют unique_ptr вместо “обычного” указателя на следующий элемент.

class Stack
{
  struct Link
  {
    T value;
    unique_ptr<Link> next;

    Link(const T &value, unique_ptr<Link> &&next)
      : value(value), next(move(next)) {}
  };

  unique_ptr<Link> list;

public:
  bool empty() const noexcept
  {
    return !list;
  }

  T& top()
  {
    assert(!empty());
    return list->value;
  }

  const T& top() const
  {
    assert(!empty());
    return list->value;
  }

  void push(const T &value)
  {
    list = make_unique<Link>(value, move(list));
  }

  void pop()
  {
    assert(!empty());
    list = move(list->next);
  }
};

Копирование этого стека невозможно, но доступны корректное перемещение, деструктор и конструктор по умолчанию, создающий пустой стек.

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

В данном случае следует написать свой деструктор, выполняющий удаление списка в цикле.

  ~Stack()
  {
    while (!empty())
    {
      auto deleter = move(list);
      list = move(deleter->next);
    }
  }

В случае наличия пользовательского деструктора, компилятор уже не будет автоматически генерировать перемещающий конструктор, поэтому надо запросить его (и соответствующий оператор присваивания) явно:

  Stack(Stack&&) = default;
  Stack& operator=(Stack&&) = default;

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

  Stack() = default;

Финальный результат целиком (вместе с конструктором копирования):

C++, HTML


shared_ptr

Другим видом стандартных “умных указателей” является пара shared_ptr и weak_ptr (они используются вместе), реализующие управление разделяемым ресурсом с помощью подсчёта числа ссылок.



Общее оглавление

Кувшинов Д.Р. © 2016