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. Як видно з рисунку, копіюється увесь масив поелементно. Якщо кількість елементів в цьому масиві велика, то цей процес займе деякий час.

C++. Виклик конструктора копіювання. Копіювання масиву

Рисунок 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).

С++. Конструктор переносу. Області дії lvalue- та rvalue- посилань

Рисунок 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 вводиться наступний конструктор з 2 параметрами:

// Конструктор, який отримує зовнішній масив 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

 


Споріднені теми