Урок №199. Конструктор переміщення і Оператор присвоювання переміщенням

  Юрій  | 

  Оновл. 31 Бер 2021  | 

 82

На уроці №197 ми говорили про std::auto_ptr, обговорювали необхідність використання семантики переміщення і розглянули деякі недоліки, які виникають при перевизначенні функцій, які використовують семантику копіювання (конструктор копіювання і оператор присвоювання копіюванням), для роботи з семантикою переміщення.

На цьому уроці ми розглянемо більш детально, як C++11 вирішив ці проблеми за допомогою конструктора переміщення і оператора присвоювання переміщенням.

Конструктор копіювання і оператор присвоювання копіюванням

Давайте трохи поговоримо про семантику копіювання.

Конструктор копіювання використовується для ініціалізації класу шляхом створення копії необхідного об’єкта. Оператор присвоювання копіюванням (або «копіююче присвоювання») використовується для копіювання одного класу в інший (існуючий) клас. За замовчуванням мова C++ автоматично надає конструктор копіювання і оператор присвоювання копіюванням, якщо ви не надали їх самі. Надані компілятором функції виконують поверхневе копіювання, що може викликати проблеми у класів, які працюють з динамічно виділеною пам’яттю. Одним з варіантів вирішення таких проблем є перевизначення конструктора копіювання і оператора присвоювання копіюванням для виконання глибокого копіювання.

Повертаючись до нашого прикладу з класом Auto_ptr (який є розумним вказівником) з попередніх уроків, давайте розглянемо версію цього класу, в якій конструктор копіювання і оператор присвоювання копіюванням перевизначені для виконання глибокого копіювання:

У програмі, наведеній вище, ми використовуємо функцію generateItem() для створення інкапсульованого розумного вказівника Item, який потім передається назад в функцію main(). У функції main() ми присвоюємо його об’єкту mainItem.

Результат виконання програми:

Item acquired
Item acquired
Item destroyed
Item acquired
Item destroyed
Item destroyed

Розглянемо виконання цієї програми детально. Тут відбувається 6 ключових дій (по одній на кожен рядок виводу):

   При створенні об’єкта mainItem всередині generateItem() створюється локальна змінна (об’єкт) item, яка ініціалізується динамічно виділеним Item, внаслідок чого ми отримуємо перший рядок виводу — Item acquired.

   Потім item повертається назад в функцію main() по значенню. Чому по значенню? Тому що item є локальною змінною і її не можна повернути по адресу або по посиланню, оскільки вона буде знищена при завершенні виконання функції generateItem(). Таким чином, item — це копія (тимчасовий об’єкт). Оскільки наш конструктор копіювання виконує глибоке копіювання, то виділяється новий Item, результатом чого є другий рядок виводу — Item acquired.

   При завершенні виконання функції generateItem() змінна item виходить з області видимості, знищуючи створений на початку Item, в результаті чого ми отримуємо третій рядок виводу — Item destroyed.

   Тимчасовий об’єкт, створений внаслідок глибокого копіювання, присвоюється для mainItem в функції main() шляхом використання оператора присвоювання копіюванням. Оскільки ми перевантажили оператор присвоювання копіюванням для виконання глибокого копіювання (замість поверхневого), то виділяється новий Item, внаслідок чого ми отримуємо четвертий рядок виводу — Item acquired.

   Операція присвоювання тимчасового об’єкта об’єкту mainItem закінчується, і тимчасовий об’єкт виходить з області видимості виразу і знищується, в результаті чого ми отримуємо п’ятий рядок виводу — Item destroyed.

   В кінці функції main() об’єкт mainItem виходить з області видимості, і ми отримуємо шостий (і останній) рядок виводу — Item destroyed.

ПриміткаВаш результат може складатися з 4-х рядків виводу, якщо ваш компілятор ігнорує значення, що повертається, функції generateItem():

Item acquired
Item acquired
Item destroyed
Item destroyed

Коротше кажучи, використовуючи конструктор копіювання і оператор присвоювання копіюванням з виконанням глибокого копіювання ми, в результаті, виділяємо і знищуємо 3 окремих об’єкти.

Неефективно, скажете ви, але, принаймні, все працює без збоїв! Однак з семантикою переміщення ми можемо досягти більшого.

Конструктор переміщення і оператор присвоювання переміщенням

У C++11 додали дві нові функції для роботи з семантикою переміщення: конструктор переміщення і оператор присвоювання переміщенням. У той час як ціль семантики копіювання полягає в тому, щоб виконувати копіювання одного об’єкта в інший, ціль семантики переміщення полягає в тому, щоб перемістити володіння ресурсами з одного об’єкта в інший (що менш затратно, ніж виконання операції копіювання).

Визначення конструктора переміщення і оператора присвоювання переміщенням виконується аналогічно визначенню конструктора копіювання і оператора присвоювання копіюванням. Однак, в той час як функції з копіюванням приймають в якості параметра константне посилання l-value, функції з переміщенням приймають в якості параметра неконстантне посилання r-value.

Ось вищенаведений клас Auto_ptr3, але вже з доданими конструктором переміщення і оператором присвоювання переміщенням. Ми не стали видаляти конструктор копіювання і оператор присвоювання копіюванням:

Все просто! Замість виконання глибокого копіювання вихідного об’єкта в неявний об’єкт, ми просто переміщуємо (крадемо) ресурси вихідного об’єкта. Під цим мається на увазі поверхневе копіювання вказівника на вихідний об’єкт в неявний (тимчасовий) об’єкт, а потім присвоювання вихідному вказівнику значення null (точніше nullptr) і в кінці видалення неявного об’єкта.

Результат виконання програми:

Item acquired
Item destroyed

Вже набагато краще!

Хід виконання цієї програми точно такий же, як і попередньої програми. Однак замість виклику конструктора копіювання і оператора присвоювання копіюванням в цій програмі викликається конструктор переміщення і оператор присвоювання переміщенням. Розглянемо детально:

   При створенні об’єкта mainItem всередині generateItem() створюється локальна змінна (об’єкт) item, яка ініціалізується динамічно виділеним Item, внаслідок чого ми отримуємо перший рядок виводу — Item acquired.

   Потім item повертається назад в функцію main() по значенню. Використовуючи семантику переміщення, програма створює тимчасовий об’єкт, в який переміщується item.

   При завершенні виконання функції generateItem() змінна item виходить з області видимості. Оскільки локальний item (який знаходиться в функції generateItem()) більше не керує вказівником на себе (цей вказівник був переміщений в тимчасовий об’єкт), тобто не володіє виділеними ресурсами, то нічого цікавого тут не відбувається.

   У функції main() тимчасовий об’єкт за допомогою оператора присвоювання переміщенням переміщується в mainItem.

   Операція присвоювання тимчасового об’єкта в mainItem завершується, і тимчасовий об’єкт виходить з області видимості виразу і знищується. Однак, оскільки тимчасовий об’єкт більше не керує вказівником (цей вказівник був переміщений в mainItem), тут теж нічого цікавого не відбувається.

   В кінці функції main() об’єкт mainItem виходить з області видимості, і ми отримуємо другий (і останній) рядок виводу — Item destroyed.

У цій програмі, замість виконання копіювання нашого Item-а двічі (один раз для конструктора копіювання і один раз для оператора присвоювання копіюванням), ми передаємо цей Item двічі. Такий розклад більш ефективний, оскільки Item створюється і знищується тільки один раз (замість трьох).

Коли викликаються конструктор переміщення і оператор присвоювання переміщенням?

Конструктор переміщення і оператор присвоювання переміщенням викликаються, коли аргументом для створення або присвоювання є r-value. Найчастіше цим r-value буде літерал або тимчасове значення (тимчасовий об’єкт).

У більшості випадків конструктор переміщення і оператор присвоювання переміщенням не надаються за замовчуванням. Однак в тих рідкісних випадках, коли вони можуть бути надані за замовчуванням, ці функції виконуватимуть те ж саме, що і конструктор копіювання разом з оператором присвоювання копіюванням — копіювання, а не переміщення.

Правило: Якщо вам потрібен конструктор переміщення і оператор присвоювання переміщенням, які виконують переміщення (а не копіювання), то вам їх потрібно надати (написати) самостійно.

Ключове розуміння семантики переміщення

Якщо ми створюємо об’єкт або виконуємо присвоювання, де аргументом є l-value, то єдине розумне, що ми можемо зробити — це скопіювати l-value. Ми не можемо сказати, що змінювати l-value безпечно, оскільки він може використовуватися в програмі пізніше. Якщо у нас є вираз a = b, то нам би дуже не хотілося, щоб b будь-яким чином був змінений в майбутньому.

Однак, якщо ми створюємо об’єкт або виконуємо присвоювання, де аргументом є r-value, то ми знаємо, що r-value — це просто деякий тимчасовий об’єкт. Замість того, щоб копіювати його (що може бути затратно), ми можемо просто перемістити його ресурси (що не так затратно) в інший об’єкт, який ми створюємо або якому присвоюємо поточний. Це безпечно, оскільки тимчасовий об’єкт буде знищений в кінці виразу в будь-якому випадку, тому ми можемо бути впевнені, що він ніколи не буде повторно використаний!

У C++11 через посилання r-value ми можемо змінювати поведінку функцій в залежності від того, чим є аргумент: r-value або l-value. А це, в свою чергу, дозволяє нам приймати більш розумні й ефективні рішення про те, як повинен працювати наш код.

У прикладах, наведених вище, в конструкторі переміщення і перевантаженні оператора присвоювання ми присвоювали для x.m_ptr значення nullptr. Це може здатися зайвим — врешті-решт, якщо x є тимчасовим r-value, то навіщо нам турбуватися про виконання будь-якого «очищення», якщо параметр x все одно буде знищений?

Справа в тому, що, коли x виходить з області видимості, викликається деструктор для знищення x.m_ptr. Якщо в цей момент x.m_ptr все ще вказує на той же об’єкт, що і m_ptr, то m_ptr перетвориться у “висячий” вказівник після знищення x.m_ptr. Коли об’єкт, який містить m_ptr, в кінцевому підсумку буде використаний (або знищений), то ми отримаємо невизначену поведінку/результати.

На наступному уроці ми розглянемо ситуації, в яких параметром x є l-value. У таких випадках x не буде негайно знищений, і його ще можна буде використовувати деякий час.

Використання семантики переміщення з l-values

У функції generateItem() класу Auto_ptr4 з вищенаведеного прикладу, коли змінна item повертається по значенню, її ресурси переміщуються, а не копіюються, навіть якщо item — це l-value. У мові C++ є правило, згідно з яким автоматичні об’єкти, які повертаються функцією по значенню, можна переміщати (а не копіювати), навіть якщо вони є l-values. В цьому є сенс, тому що item все одно буде знищений в кінці функції в будь-якому випадку!

Відключення копіювання

У класі Auto_ptr4 з прикладу, наведеного вище, ми залишили конструктор копіювання і оператор присвоювання копіюванням з метою порівняння. Але в класах з підтримкою переміщення іноді бажано видалити конструктор копіювання і оператор присвоювання копіюванням, щоб переконатися, що копіювання об’єктів ніколи не буде виконано. У нашому випадку з Auto_ptr ми не хочемо копіювати об’єкти, тому видалимо все зайве:

Якщо ви спробуєте передати l-value в Auto_ptr5 по значенню, то компілятор скаржитиметься, що конструктор копіювання, необхідний для ініціалізації аргументу конструктора копіювання, був видалений. Це добре, оскільки ми повинні передавати l-value в Auto_ptr5 по константному посиланню в будь-якому випадку!

Нарешті, Auto_ptr5 — це відмінний приклад класу розумного вказівника. Більше того, Стандартна бібліотека С++ має клас, дуже схожий на цей (який ви повинні використовувати) — std::unique_ptr. Детально про std::unique_ptr ми поговоримо на відповідних уроках.

Семантика копіювання vs. Семантика переміщення

Розглянемо інший клас, який використовує динамічно виділену пам’ять — простий шаблон класу DynamicArray. Цей клас має конструктор копіювання і оператор присвоювання копіюванням, які виконують глибоке копіювання:

Тепер давайте спробуємо використати цей клас на практиці. Наприклад, давайте виділимо масив (об’єкт класу DynamicArray), який зберігатиме мільйон цілочисельних значень. Щоб перевірити ефективність цього коду, ми використовуватимемо клас Timer з уроку №137. Класом Timer ми визначимо швидкість виконання нашого коду і покажемо різницю в продуктивності між семантикою копіювання і семантикою переміщення.

Результат виконання програми на комп’ютері автора в режимі “Release”:

0.0225438

Примітка: Результат вимірюється в секундах.

Тепер давайте запустимо цю ж програму, замінивши конструктор копіювання і оператор присвоювання копіюванням на конструктор переміщення і оператор присвоювання переміщенням:

Результат виконання програми на комп’ютері автора в режимі “Release”:

0.0131518

Порівнюємо час виконання двох програм: 0.0131518 / 0.0225438 = 58.3%. Версія з використанням семантики переміщення була майже на 42% швидше версії з використанням семантики копіювання!

Оцінити статтю:

1 Зірка2 Зірки3 Зірки4 Зірки5 Зірок (1 оцінок, середня: 5,00 з 5)
Loading...

Залишити відповідь

Ваш E-mail не буде опублікований. Обов'язкові поля відмічені *