На уроці №197 ми говорили про std::auto_ptr, обговорювали необхідність використання семантики переміщення і розглянули деякі недоліки, які виникають при перевизначенні функцій, які використовують семантику копіювання (конструктор копіювання і оператор присвоювання копіюванням), для роботи з семантикою переміщення.
На цьому уроці ми розглянемо більш детально, як C++11 вирішив ці проблеми за допомогою конструктора переміщення і оператора присвоювання переміщенням.
- Конструктор копіювання і оператор присвоювання копіюванням
- Конструктор переміщення і оператор присвоювання переміщенням
- Коли викликаються конструктор переміщення і оператор присвоювання переміщенням?
- Ключове розуміння семантики переміщення
- Використання семантики переміщення з l-values
- Відключення копіювання
- Семантика копіювання vs. Семантика переміщення
Конструктор копіювання і оператор присвоювання копіюванням
Давайте трохи поговоримо про семантику копіювання.
Конструктор копіювання використовується для ініціалізації класу шляхом створення копії необхідного об’єкта. Оператор присвоювання копіюванням (або «копіююче присвоювання») використовується для копіювання одного класу в інший (існуючий) клас. За замовчуванням мова C++ автоматично надає конструктор копіювання і оператор присвоювання копіюванням, якщо ви не надали їх самі. Надані компілятором функції виконують поверхневе копіювання, що може викликати проблеми у класів, які працюють з динамічно виділеною пам’яттю. Одним з варіантів вирішення таких проблем є перевизначення конструктора копіювання і оператора присвоювання копіюванням для виконання глибокого копіювання.
Повертаючись до нашого прикладу з класом Auto_ptr (який є розумним вказівником) з попередніх уроків, давайте розглянемо версію цього класу, в якій конструктор копіювання і оператор присвоювання копіюванням перевизначені для виконання глибокого копіювання:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
#include <iostream> template<class T> class Auto_ptr3 { T* m_ptr; public: Auto_ptr3(T* ptr = nullptr) :m_ptr(ptr) { } ~Auto_ptr3() { delete m_ptr; } // Конструктор копіювання, який виконує глибоке копіювання x.m_ptr в m_ptr Auto_ptr3(const Auto_ptr3& x) { m_ptr = new T; *m_ptr = *x.m_ptr; } // Оператор присвоювання копіюванням, який виконує глибоке копіювання x.m_ptr в m_ptr Auto_ptr3& operator=(const Auto_ptr3& x) { // Перевірка на самоприсвоювання if (&x == this) return *this; // Видаляємо все, що до цього моменту може зберігати вказівник delete m_ptr; // Копіюємо переданий об'єкт m_ptr = new T; *m_ptr = *x.m_ptr; return *this; } T& operator*() const { return *m_ptr; } T* operator->() const { return m_ptr; } bool isNull() const { return m_ptr == nullptr; } }; class Item { public: Item() { std::cout << "Item acquired\n"; } ~Item() { std::cout << "Item destroyed\n"; } }; Auto_ptr3<Item> generateItem() { Auto_ptr3<Item> item(new Item); return item; // це значення, що повертається, призведе до виклику конструктора копіювання } int main() { Auto_ptr3<Item> mainItem; mainItem = generateItem(); // ця операція присвоювання призведе до виклику оператора присвоювання копіюванням return 0; } |
У програмі, наведеній вище, ми використовуємо функцію 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, але вже з доданими конструктором переміщення і оператором присвоювання переміщенням. Ми не стали видаляти конструктор копіювання і оператор присвоювання копіюванням:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
#include <iostream> template<class T> class Auto_ptr4 { T* m_ptr; public: Auto_ptr4(T* ptr = nullptr) :m_ptr(ptr) { } ~Auto_ptr4() { delete m_ptr; } // Конструктор копіювання, який виконує глибоке копіювання x.m_ptr в m_ptr Auto_ptr4(const Auto_ptr4& x) { m_ptr = new T; *m_ptr = *x.m_ptr; } // Конструктор переміщення, який передає право власності на x.m_ptr в m_ptr Auto_ptr4(Auto_ptr4&& x) : m_ptr(x.m_ptr) { x.m_ptr = nullptr; // ми поговоримо про це трішки пізніше } // Оператор присвоювання копіюванням, який виконує глибоке копіювання x.m_ptr в m_ptr Auto_ptr4& operator=(const Auto_ptr4& x) { // Перевірка на самоприсвоювання if (&x == this) return *this; // Видаляємо все, що до цього моменту може зберігати вказівник delete m_ptr; // Копіюємо переданий об'єкт m_ptr = new T; *m_ptr = *x.m_ptr; return *this; } // Оператор присвоювання переміщенням, який передає право власності на x.m_ptr в m_ptr Auto_ptr4& operator=(Auto_ptr4&& x) { // Перевірка на самоприсвоювання if (&x == this) return *this; // Видаляємо все, що до цього моменту може зберігати вказівник delete m_ptr; // Передаємо право власності на x.m_ptr в m_ptr m_ptr = x.m_ptr; x.m_ptr = nullptr; // ми поговоримо про це трішки пізніше return *this; } T& operator*() const { return *m_ptr; } T* operator->() const { return m_ptr; } bool isNull() const { return m_ptr == nullptr; } }; class Item { public: Item() { std::cout << "Item acquired\n"; } ~Item() { std::cout << "Item destroyed\n"; } }; Auto_ptr4<Item> generateItem() { Auto_ptr4<Item> item(new Item); return item; // це значення, що повертається, призведе до виклику конструктора переміщення } int main() { Auto_ptr4<Item> mainItem; mainItem = generateItem(); // ця операція присвоювання призведе до виклику оператора присвоювання переміщенням return 0; } |
Все просто! Замість виконання глибокого копіювання вихідного об’єкта в неявний об’єкт, ми просто переміщуємо (крадемо) ресурси вихідного об’єкта. Під цим мається на увазі поверхневе копіювання вказівника на вихідний об’єкт в неявний (тимчасовий) об’єкт, а потім присвоювання вихідному вказівнику значення 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 ми не хочемо копіювати об’єкти, тому видалимо все зайве:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
template<class T> class Auto_ptr5 { T* m_ptr; public: Auto_ptr5(T* ptr = nullptr) :m_ptr(ptr) { } ~Auto_ptr5() { delete m_ptr; } // Конструктор копіювання - забороняємо будь-яке копіювання! Auto_ptr5(const Auto_ptr5& x) = delete; // Конструктор переміщення, який передає право власності на x.m_ptr в m_ptr Auto_ptr5(Auto_ptr5&& x) : m_ptr(x.m_ptr) { x.m_ptr = nullptr; } // Оператор присвоювання копіюванням - забороняємо будь-яке копіювання! Auto_ptr5& operator=(const Auto_ptr5& x) = delete; // Оператор присвоювання переміщенням, який передає право власності на x.m_ptr в m_ptr Auto_ptr5& operator=(Auto_ptr5&& x) { // Перевірка на самоприсвоювання if (&x == this) return *this; // Видаляємо все, що може зберігати вказівник до цього моменту delete m_ptr; // Передаємо право власності на x.m_ptr в m_ptr m_ptr = x.m_ptr; x.m_ptr = nullptr; return *this; } T& operator*() const { return *m_ptr; } T* operator->() const { return m_ptr; } bool isNull() const { return m_ptr == nullptr; } }; |
Якщо ви спробуєте передати l-value в Auto_ptr5 по значенню, то компілятор скаржитиметься, що конструктор копіювання, необхідний для ініціалізації аргументу конструктора копіювання, був видалений. Це добре, оскільки ми повинні передавати l-value в Auto_ptr5 по константному посиланню в будь-якому випадку!
Нарешті, Auto_ptr5 — це відмінний приклад класу розумного вказівника. Більше того, Стандартна бібліотека С++ має клас, дуже схожий на цей (який ви повинні використовувати) — std::unique_ptr. Детально про std::unique_ptr ми поговоримо на відповідних уроках.
Семантика копіювання vs. Семантика переміщення
Розглянемо інший клас, який використовує динамічно виділену пам’ять — простий шаблон класу DynamicArray. Цей клас має конструктор копіювання і оператор присвоювання копіюванням, які виконують глибоке копіювання:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
template <class T> class DynamicArray { private: T* m_array; int m_length; public: DynamicArray(int length) : m_array(new T[length]), m_length(length) { } ~DynamicArray() { delete[] m_array; } // Конструктор копіювання DynamicArray(const DynamicArray &arr) : m_length(arr.m_length) { m_array = new T[m_length]; for (int i = 0; i < m_length; ++i) m_array[i] = arr.m_array[i]; } // Оператор присвоювання копіюванням DynamicArray& operator=(const DynamicArray &arr) { if (&arr == this) return *this; delete[] m_array; m_length = arr.m_length; m_array = new T[m_length]; for (int i = 0; i < m_length; ++i) m_array[i] = arr.m_array[i]; return *this; } int getLength() const { return m_length; } T& operator[](int index) { return m_array[index]; } const T& operator[](int index) const { return m_array[index]; } }; |
Тепер давайте спробуємо використати цей клас на практиці. Наприклад, давайте виділимо масив (об’єкт класу DynamicArray), який зберігатиме мільйон цілочисельних значень. Щоб перевірити ефективність цього коду, ми використовуватимемо клас Timer з уроку №137. Класом Timer ми визначимо швидкість виконання нашого коду і покажемо різницю в продуктивності між семантикою копіювання і семантикою переміщення.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
#include <iostream> #include <chrono> // для функцій з std::chrono template <class T> class DynamicArray { private: T* m_array; int m_length; public: DynamicArray(int length) : m_array(new T[length]), m_length(length) { } ~DynamicArray() { delete[] m_array; } // Конструктор копіювання DynamicArray(const DynamicArray &arr) : m_length(arr.m_length) { m_array = new T[m_length]; for (int i = 0; i < m_length; ++i) m_array[i] = arr.m_array[i]; } // Оператор присвоювання копіюванням DynamicArray& operator=(const DynamicArray &arr) { if (&arr == this) return *this; delete[] m_array; m_length = arr.m_length; m_array = new T[m_length]; for (int i = 0; i < m_length; ++i) m_array[i] = arr.m_array[i]; return *this; } int getLength() const { return m_length; } T& operator[](int index) { return m_array[index]; } const T& operator[](int index) const { return m_array[index]; } }; class Timer { private: // Використовуємо псевдоніми типів для зручного доступу до вкладених типів using clock_t = std::chrono::high_resolution_clock; using second_t = std::chrono::duration<double, std::ratio<1> >; std::chrono::time_point<clock_t> m_beg; public: Timer() : m_beg(clock_t::now()) { } void reset() { m_beg = clock_t::now(); } double elapsed() const { return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count(); } }; // Повертаємо копію arr зі значеннями, помноженими на 2 DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr) { DynamicArray<int> dbl(arr.getLength()); for (int i = 0; i < arr.getLength(); ++i) dbl[i] = arr[i] * 2; return dbl; } int main() { Timer t; DynamicArray<int> arr(1000000); for (int i = 0; i < arr.getLength(); i++) arr[i] = i; arr = cloneArrayAndDouble(arr); std::cout << t.elapsed(); } |
Результат виконання програми на комп’ютері автора в режимі “Release”:
0.0225438
Примітка: Результат вимірюється в секундах.
Тепер давайте запустимо цю ж програму, замінивши конструктор копіювання і оператор присвоювання копіюванням на конструктор переміщення і оператор присвоювання переміщенням:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
#include <iostream> #include <chrono> // для функцій з std::chrono template <class T> class DynamicArray { private: T* m_array; int m_length; public: DynamicArray(int length) : m_array(new T[length]), m_length(length) { } ~DynamicArray() { delete[] m_array; } // Конструктор копіювання DynamicArray(const DynamicArray &arr) = delete; // Оператор присвоювання копіюванням DynamicArray& operator=(const DynamicArray &arr) = delete; // Конструктор переміщення DynamicArray(DynamicArray &&arr) : m_length(arr.m_length), m_array(arr.m_array) { arr.m_length = 0; arr.m_array = nullptr; } // Оператор присвоювання переміщенням DynamicArray& operator=(DynamicArray &&arr) { if (&arr == this) return *this; delete[] m_array; m_length = arr.m_length; m_array = arr.m_array; arr.m_length = 0; arr.m_array = nullptr; return *this; } int getLength() const { return m_length; } T& operator[](int index) { return m_array[index]; } const T& operator[](int index) const { return m_array[index]; } }; class Timer { private: // Використовуємо псевдоніми типів для зручного доступу до вкладених типів using clock_t = std::chrono::high_resolution_clock; using second_t = std::chrono::duration<double, std::ratio<1> >; std::chrono::time_point<clock_t> m_beg; public: Timer() : m_beg(clock_t::now()) { } void reset() { m_beg = clock_t::now(); } double elapsed() const { return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count(); } }; // Повертаємо копію arr зі значеннями, помноженими на 2 DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr) { DynamicArray<int> dbl(arr.getLength()); for (int i = 0; i < arr.getLength(); ++i) dbl[i] = arr[i] * 2; return dbl; } int main() { Timer t; DynamicArray<int> arr(1000000); for (int i = 0; i < arr.getLength(); i++) arr[i] = i; arr = cloneArrayAndDouble(arr); std::cout << t.elapsed(); } |
Результат виконання програми на комп’ютері автора в режимі “Release”:
0.0131518
Порівнюємо час виконання двох програм: 0.0131518 / 0.0225438 = 58.3%
. Версія з використанням семантики переміщення була майже на 42% швидше версії з використанням семантики копіювання!