C++. Конструктор переноса и оператор переноса

Конструктор переноса и оператор переноса. Назначение. Примеры. Ключевое слово noexcept. Пример класса Vector (динамический массив)

Перед изучением данной темы рекомендуется ознакомиться со следующей темой:


Содержание


Поиск на других ресурсах:

1. Какие специальные функции класса предоставляются компилятором по умолчанию? Пример

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

  • конструктор по умолчанию (default constructor) или конструктор без параметров. Используется для создания экземпляра класса, если в классе не реализованы другие конструкторы;
  • деструктор;
  • конструктор копирования. Использует побитовое копирование данных экземпляров. Этот конструктор обязательно нужно переопределять в классах, где память для данных выделяется динамически. Если память в классах не выделяется динамически (с помощью указателей *), то этот конструктор переопределять не обязательно;
  • оператор копирования. Использует побитовое копирование данных экземпляров. Этот оператор обязательно нужно переопределять в классах, где происходит динамическое выделение памяти под данные. Если в классе нет динамического выделения памяти, то оператро копирования можно и не переопределять;
  • конструктор переноса (перемещения). Этот конструктор рекомендуется переопределять в классах, которые могут содержать (или содержат) большие массивы данных память для которых выделена динамически;
  • оператор переноса (перемещения). Имеет такое же предназначение как конструктор переноса.

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

Пример. Пусть задан пустой класс (заглушка) с именем MyClass

class MyClass
{
};

Для класса MyClass компилятор формирует 6 специальных функций по умолчанию. Сигнатура этих функций следующая:

  • MyClass() – конструктор по умолчанию. У этого конструктора нет параметров. Он актуален когда в классе нет ни одного конструктора, инициализирующего внутренние поля (данные) класса;
  • ~MyClass() – деструктор. Это специальная функция, вызываемая при уничтожении объекта класса. В этой функции целесообразно вписывать код освобождения выделенных ресурсов класса (освобождение памяти под данные, закрытие открытых файлов и т.п.);
  • MyClass(const MyClass&) – конструктор копирования. Этот конструктор реализует так называемое побитовое копирование. Более подробно о побитовом копировании и использовании конструктора копирования можно прочитать здесь и здесь;
  • operator(const MyClass&) – оператор копирования. Реализует так называемое побитовое копирование. Более подробно об особенностях побитового копирования можно прочесть здесь;
  • MyClass(MyClass&&) – конструктор переноса (описывается в данной теме);
  • operator(MyClass&&) – оператор переноса (описывается в данной теме).

 

2. Конструктор переноса и оператор переноса. Назначение. Особенности использования. Общая форма

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

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

Также если возникает необходимость использования этих конструкций, то рекомендуется их добавлять в паре (обе конструкции).

Если в классе не реализован конструктор переноса, то его вызов заменяется конструктором копирования. Если в классе не реализован оператор переноса, то его вызов заменяется оператором копирования.

Общая форма объявления конструктора переноса в классе

ClassName(ClassName&& rObj) noexcept
{
  ...
}

здесь

  • ClassName – имя класса и конструктора;
  • rObj – ссылка на ссылку на временный экземпляр класса (rvalue — экземпляр), значение которого будет скопировано в текущий экземпляр.

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

 

3. Рисунок, демонстрирующий назначение конструктора переноса в классе

На рисунке 1 изображен случай, когда в классе не реализован конструктор переноса и вместо него вызывается конструктор копирования. Рассматривается копирование большого массива данных A размерностью n элементов для класса с именем MyClass. Как видно из рисунка, копируется весь массив поэлементно. Если количество элементов в этом массиве велико, этот процесс займет некоторое время.

С++. Вызов конструктора копирования

Рисунок 1. Действия, выполняемые конструктором копирования. Копирование всего массива

После добавления конструктора переноса в класс MyClass, данные уже не копируются (рисунок 2). Это дает существенный выигрыш в скорости выполнения программы, если количество элементов в массиве obj.A значительно (например, n = 1E6).

С++. Конструктор переноса. Присваивание (перенаправление) указателя на массив-источник

Рисунок 2. Конструктор переноса. Присваивание (перенаправление) указателя на массив-источник. Данные не копируются

 

4. Случаи вызова конструктора переноса. Ссылки типа rvalue- и lvalue-. Действия, выполняемые в конструкторе переноса

В данной теме рассматриваются только общие особенности ссылок типа rvalue- и lvalue-. Подробный обзор lvalue- и rvalue, это уже совсем другая тема, требующая отдельного основательного исследования.

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

Ниже рассматривается одна из возможных ситуаций вызова конструктора переноса.

В наиболее общем случае, любая функция, возвращающая экземпляр класса ClassName имеет вид:

ClassName SomeFunc(parameters)
{
  ...
}

Вызов конструктора переноса происходит при вызове функции SomeFunc() в момент объявления экземпляра класса

ClassName obj = SomeFunc(...); // здесь вызывается конструктор переноса

Экземпляр obj размещается в левой части оператора присваивания и есть ссылкой типа lvalue. Такая ссылка имеет область определения в пределах фигурных скобок { } и доступна после завершения текущего выражения. Иными словами, можно использовать экземпляр obj в дальнейшем, например

obj.SomeInternalMethod(...);

здесь SomeInternalMethod() – некоторый public-метод из класса ClassName.

В свою очередь, функция SumeFunc() размещается в правой части оператора =. Возвращаемый функцией результат есть экземпляром класса ClassName. В данном случае, этот экземпляр является временным объектом, относящимся к ссылке типа rvalue. Область действия данного временного объекта определяется текущим выражением. Этот временный экземпляр после выполнения присваивания уже не будет использоваться (рисунок 3).

С++. Случай, когда вызывается конструктор переноса

Рисунок 3. Случай, когда вызывается конструктор переноса. Области действия lvalue- и rvalue- ссылок

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

В конструкторе переноса выполняются следующие действия:

  • внутренние указатели должны быть перенаправлены на внешние данные, которые должны быть скопированы во внутренние поля класса. Иными словами, внутренние указатели получают значения адресов областей памяти, содержащих данные получаемые извне (смотрите пример ниже).

 

5. Оператор переноса. Общая форма

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

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

ClassName obj;
obj = SomeFunc(parameters); // здесь вызывается оператор переноса

здесь SomeFunc() – некоторая функция, которая возвращает экземпляр класса ClassName.

Если в классе не реализован оператор переноса, то этот оператор заменяется оператором копирования.

Общая форма объявления оператора переноса в классе:

ClassName& operator=(ClassName&& obj)
{
  ...
}

здесь

  • ClassName – имя класса;
  • obj – объект, который есть ссылкой типа rvalue в вызывающем выражении.

В операторе переноса последовательность выполняемых действий больше, чем в конструкторе копирования, а именно:

  • проверка, не происходит ли присваивание экземпляра самому себе в случаях, когда функция может каким-либо образом возвращать этот же экземпляр (см. примеры ниже);
  • освобождение памяти под выделенные внутренние данные. Экземпляр lvalue уже создан ранее и в нем уже есть некоторые данные;
  • присваивание внутренним указателям адресов данных, которые необходимо скопировать в текущий экземпляр.

Более подробно реализацию оператора переноса смотрите в приведенном ниже примере.

 

6. Пример реализации класса Vector (динамический массив). Базовый набор методов. Конструктор переноса и оператор переноса в классе

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

Также можно расширить класс путем добавления новых методов, оперирующих массивом. Например, можно добавить методы реверсирования массива, конкатенации, доступа к отдельным элементам массива по индексу и т.д.

 

6.1. Условие задачи

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

6.2. Решение
6.2.1. Составляющие класса Vector

Класс содержит следующие составляющие:

  • A – массив типа double*;
  • count – количество элементов массива;
  • Vector(double*, int) – параметризированный конструктор, инициализирующий данные класса;
  • Vector() – конструктор без параметров;
  • Vector(const Vector&) – конструктор копирования;
  • Vector(Vector&&) – конструктор переноса;
  • operator=(const Vector&) – оператор копирования;
  • operator=(Vector&&) – оператор переноса;
  • ~Vector() – деструктор;
  • Free() – внутренняя private-функция, освобождающая данные, выделенные под массив A;
  • CopyArray() – внутренняя private-функция, копирующая внешние данные во внутренний массив;
  • метод Set() – реализует копирование внешнего массива во внутренний;
  • метод Print() – выводит массив на экран. Используется для тестирования.

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

 

6.2.2. Ввод внутренних полей класса

Динамический массив элементов типа double объявляется как double*. Количество элементов в массиве равно count. После ввода этих переменных класс имеет следующий вид

class Vector
{
private:
  double* A; // массив типа double
  int count; // количество элементов в массиве
}

В нашем случае принимается договоренность, что проверку на наличие пустого массива осуществляем на основании значения count. Если count>0, то массив не пуст. Во всех остальных случаях массив считается пустым. Контроль за заполнением массива полностью лежит на переменной count.

 

6.2.3. Внутренние private-функции Free(), CopyArray()

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

  • освобождение памяти, выделенной под массив;
  • копирование внешнего массива во внутренний массив A.

Поэтому в классе целесообразно реализовать соответствующие внутренние private-функции Free() и CopyArray(). После ввода функций, программный код класса следующий:

class Vector
{
private:
  double* A; // массив типа double
  int count; // количество элементов в массиве

  // Функция, которая освобождает память, выделенную под массив.
  void Free()
  {
    // Наличие элементов в массиве контролируется по переменной count
    if (count > 0)
    {
      delete[] A;
      count = 0;
    }
  }

  // Функция, копирующая внешний массив в текущий
  void CopyArray(double* A, int count)
  {
    // 1. Если нужно, то освободить память
    Free();

    try
    {
      // 2. Выделить память под внутренний массив this->A
      this->A = new double[count];

      // 3. Скопировать данные во внутренний массив
      this->count = count;
      for (int i = 0; i < count; i++)
        this->A[i] = A[i];
    }
    catch (bad_alloc e)
    {
      // 4. Если память не выделена, то перехват исключительной ситуации
      cout << e.what() << endl;
    }
  }
}

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

В функции Free() факт наличия частей в массиве проверяется по переменной count (count==0). Поэтому, в случае пустого массива, не нужно каждый раз присваивать указателю A значение nullptr.

 

6.2.4. Конструктор с двумя параметрами Vector(double*, int)

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

// Конструктор, который получает внешний массив A
Vector(double* A, int count)
{
  this->count = 0;
  CopyArray(A, count); // вызвать функцию копирования
}

В конструкторе используется внутренняя функция CopyArray() для копирования данных во внутренний массив A.

 

6.2.5. Деструктор ~Vector()

После объявления параметризированного конструктора, обязательно объявляется деструктор, вызывающий внутреннюю функцию Free().

// Деструктор
~Vector()
{
  Free();
}

 

6.2.6. Конструктор без параметров Vector()

Еще один конструктор, который может использоваться для создания пустого массива – конструктор без параметров. Данный конструктор делегирует свои полномочия конструктору с двумя параметрами. Конструктор вводится в раздел public.

// Конструктор без параметров - делегирует полномочия конструктору с двумя параметрами
Vector() : Vector(nullptr, 0) {   }

Это единственный случай в программе, когда используется nullptr для присваивания значения указателю A. Во всех остальных случаях не нужно присваивать указателю A значение nullptr, поскольку контроль за фактом наличия пустого массива полностью возлагается на переменную count.

 

6.2.7. Конструктор копирования Vector(const Vector&)

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

В нашем случае, код конструктора копирования чрезвычайно прост. Вызывается функция CopyArray(), выполняющая всю необходимую работу.

// Конструктор копирования
Vector(const Vector& obj)
{
  CopyArray(obj.A, obj.count);
}

 

6.2.8. Оператор копирования operator=(const Vector&)

Оператор копирования необходимо реализовывать в тех случаях, когда в классе используется динамическое выделение памяти во внутренние поля. В нашем случае, код оператора копирования содержит вызов функции CopyArray().

// Оператор копирования
Vector& operator=(Vector& obj)
{
  CopyArray(obj.A, obj.count);
  return *this;
}

 

6.2.9. Конструктор переноса Vector(Vector&&)

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

// Конструктор переноса
Vector(Vector&& obj) noexcept
{
  // 1. Перенаправить A на obj.A и изменить count
  A = obj.A;
  count = obj.count;

  // 2. Занулить кол-во элементов в исходном массиве,
  //    это необходимо, чтобы избежать лишнего освобождения
  //    памяти в деструкторе, что может вызвать исключительную ситуацию
  obj.count = 0;
}

В конструкторе переноса интерес представляет строка

obj.count = 0;

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

A = obj.A;

у нас получается ситуация, что оба указателя (A и obj.A) указывают на один и тот же участок памяти. В случае освобождения памяти под указатели, один и тот же участок памяти будет освобождаться дважды, а это приведет к генерированию исключительной ситуации. Чтобы избежать этого, количество элементов во временном объекте obj.count устанавливается равным 0. При вызове функции Free(), освобождающей память, происходит проверка count на ненулевое значение, если count==0, то память не освобождается, а, следовательно, лишнего (ненужного) освобождения памяти не произойдет.

 

6.2.10. Оператор переноса operator=(Vector&&)

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

// Оператор переноса
Vector& operator=(Vector&& obj) noexcept
{
  // 1. Проверка, нет ли присваивания самому себе
  if (&obj == this)
    return *this;

  // 2. Освободить предварительно выделенную память под массив A
  Free();

  // 3. Перенаправить указатель A на obj.A и присвоить другое к-во эл-в
  count = obj.count;
  A = obj.A;

  // 4. Обнулить obj.count, чтобы избежать двойного освобождения
  //    одного и того же участка памяти
  obj.count = 0;

  // 5. Вернуть текущий экземпляр
  return *this;
}

 

6.2.11. Метод Set(double*, int). Установить новый массив

С целью демонстрации, в классе реализован метод Set(), производящий копию внешнего массива, являющегося входным параметром, во внутренний массив.

// Метод Set() - копирует внешние данные во внутренний массив
void Set(double* A, int count)
{
  CopyArray(A, count);
}

Ошибочным будет присвоение вроде

this->A = A;

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

 

6.2.12. Метод Print(string). Вывести массив с заданным сообщением

В целях получения текущего состояния класса, вводится метод Print().

// Вывод массива
void Print(string msg)
{
  cout << msg << endl;
  if (count > 0)
  {
    // Если в массиве есть элементы
    for (int i = 0; i < count; i++)
      cout << A[i] << " ";
    cout << endl;
  }
  else
  {
    // Если пустой массив
    cout << "{ }" << endl;
  }
}

 

6.2.13. Общая структура класса Vector

После выполнения пунктов 4.2.2 – 4.2.13 класс Vector в сокращенном виде будет следующим.

// Класс Vector - динамический массив чисел типа double
class Vector
{
private:
  double* A; // массив типа double
  int count; // количество элементов в массиве

  // Функция, которая освобождает массив
  void Free()
  {
    ...
  }

  // Функция, которая копирует внешний массив в текущий
  void CopyArray(double* A, int count)
  {
    ...
  }

public:
  // Конструктор, который получает массив A
  Vector(double* A, int count)
  {
    ...
  }

  // Конструктор без параметров
  Vector() : Vector(nullptr, 0) {   }

  // Конструктор копирования
  Vector(const Vector& obj)
  {
    ...
  }

  // Оператор копирования
  Vector& operator=(Vector& obj)
  {
    ...
  }

  // Конструктор переноса
  Vector(Vector&& obj) noexcept
  {
    ...
  }

  // Оператор переноса
  Vector& operator=(Vector&& obj) noexcept
  {
    ...
  }

  // Деструктор
  ~Vector()
  {
    ...
  }

  // Метод Set() - копирует внешние данные во внутренний массив
  void Set(double* A, int count)
  {
    ...
  }

  // Вывод массива
  void Print(string msg)
  {
    ...
  }
};

 

6.2.14. Метод GetV(). Получить произвольный массив

С целью демонстрации вызова конструктора переноса и оператора переноса вне класса Vector вводится функция GetV(), которая формирует произвольный массив и возвращает экземпляр типа Vector.

// Внешняя функция, необходимая для демонстрации вызова
// конструктора переноса и оператора переноса.
// Функция возвращает экземпляр типа Vector.
Vector GetV()
{
  double A[] = { 2.3, 4.5, 1.7, 2.8 };
  Vector AV(A, 4);
  return AV;
}

 

6.2.15. Функция main()

Тест работы класса Vector происходит в функции main().

void main()
{
  // 1. Сформировать экземпляр с помощью обычного конструктора
  double AD[] = { 2.8, 1.3, -0.9, 12.3 };
  Vector v1(AD, 4);
  v1.Print("v1");

  // 2. Вызов конструктора переноса
  Vector v2 = GetV();
  v2.Print("v2");

  // 3. Вызов оператора переноса
  Vector v3;
  v3 = GetV();
  v3.Print("v3");

  // 4. Демонстрация работы метода Set()
  Vector v4;
  v4.Set(AD, 4);
  v4.Print("v4");
}

 

6.2.16. Общая структура программы

В наиболее общем случае структура программы имеет вид

#include <iostream>
using namespace std;

// Класс Vector - динамический массив чисел типа double
class Vector
{
  ...
};

// Функция возвращает экземпляр типа Vector.
Vector GetV()
{
  ...
}

void main()
{
  ...
}

 

6.2.17. Запуск программы

После объединения всех вышеприведенных фрагментов кода запуск программы выдаст следующий результат

v1
2.8 1.3 -0.9 12.3
v2
2.3 4.5 1.7 2.8
v3
2.3 4.5 1.7 2.8
v4
2.8 1.3 -0.9 12.3

 


Связанные темы