На цьому уроці ми розглянемо поверхневе і глибоке копіювання в мові C++.
Поверхневе копіювання
Оскільки мова C++ не може знати наперед все про ваш клас, то конструктор копіювання і оператор присвоювання, які C++ надає за замовчуванням, використовують почленний метод копіювання — поверхневе копіювання. Це означає, що C++ виконує копіювання для кожного члена класу індивідуально (використовуючи оператор присвоювання за замовчуванням замість перевантаження оператора присвоювання і пряму ініціалізацію замість конструктора копіювання). Коли класи прості (наприклад, в них немає членів з динамічно виділеною пам’яттю), то ніяких проблем з цим не повинно виникати.
Розглянемо наступний клас Drob:
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 |
#include <iostream> #include <cassert> class Drob { private: int m_numerator; int m_denominator; public: // Конструктор за замовчуванням Drob(int numerator=0, int denominator=1) : m_numerator(numerator), m_denominator(denominator) { assert(denominator != 0); } friend std::ostream& operator<<(std::ostream& out, const Drob &d1); }; std::ostream& operator<<(std::ostream& out, const Drob &d1) { out << d1.m_numerator << "/" << d1.m_denominator; return out; } |
Конструктор копіювання і оператор присвоювання за замовчуванням, які надаються компілятором автоматично, виглядають приблизно так:
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 |
#include <iostream> #include <cassert> class Drob { private: int m_numerator; int m_denominator; public: // Конструктор за замовчуванням Drob(int numerator=0, int denominator=1) : m_numerator(numerator), m_denominator(denominator) { assert(denominator != 0); } // Конструктор копіювання Drob(const Drob &d) : m_numerator(d.m_numerator), m_denominator(d.m_denominator) { } Drob& operator= (const Drob &drob); friend std::ostream& operator<<(std::ostream& out, const Drob &d1); }; std::ostream& operator<<(std::ostream& out, const Drob &d1) { out << d1.m_numerator << "/" << d1.m_denominator; return out; } // Перевантаження оператора присвоювання Drob& Drob::operator= (const Drob &drob) { // Перевірка на самоприсвоювання if (this == &drob) return *this; // Виконуємо копіювання m_numerator = drob.m_numerator; m_denominator = drob.m_denominator; // Повертаємо поточний об'єкт, щоб мати можливість виконати "ланцюжок" операцій присвоювання return *this; } |
Оскільки ці конструктор копіювання і оператор присвоювання за замовчуванням відмінно підходять для виконання копіювання з об’єктами цього класу, то дійсно немає ніякого сенсу писати тут свої власні версії конструктора копіювання і перевантаження оператора.
Однак при роботі з класами, в яких динамічно виділяється пам’ять, почленне (поверхневе) копіювання може викликати проблеми! Це пов’язано з тим, що при поверхневому копіюванні вказівника копіюється тільки адреса вказівника — ніяких дій по вмісту адреси вказівника не виконується. Наприклад:
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 |
#include <cstring> // для strlen() #include <cassert> // для assert() class SomeString { private: char *m_data; int m_length; public: SomeString(const char *source="") { assert(source); // перевіряємо, чи не є source нульовим рядком // Визначаємо довжину source + ще один символ для нуль-термінатора (символ завершення рядка) m_length = strlen(source) + 1; // Виділяємо достатньо пам'яті для зберігання значення, що копіюється, відповідно до довжини цього значення m_data = new char[m_length]; // Копіюємо значення по символам в нашу виділену пам'ять for (int i=0; i < m_length; ++i) m_data[i] = source[i]; // Переконуємося, що рядок завершений m_data[m_length-1] = '\0'; } ~SomeString() // деструктор { // Звільняємо пам'ять, виділену для нашого рядка delete[] m_data; } char* getString() { return m_data; } int getLength() { return m_length; } }; |
Вищенаведений клас — це звичайний рядковий клас, в якому виділяється пам’ять для зберігання переданого рядка. Тут ми не визначали конструктор копіювання або перевантаження оператора присвоювання. Мова C++ надасть конструктор копіювання і оператор присвоювання за замовчуванням, які виконуватимуть поверхневе копіювання. Конструктор копіювання виглядає наступним чином:
1 2 3 4 |
SomeString::SomeString(const SomeString &source) : m_length(source.m_length), m_data(source.m_data) { } |
Тут m_data
— це всього лише поверхнева копія вказівника source.m_data
, тому тепер вони обидва вказують на одну і ту ж адресу в пам’яті. Тепер розглянемо наступний фрагмент коду:
1 2 3 4 5 6 7 8 9 10 11 |
int main() { SomeString hello("Hello, world!"); { SomeString copy = hello; // використовується конструктор копіювання за замовчуванням } // об'єкт copy є локальною змінною, яка знищується тут. Деструктор видаляє значення-рядок об'єкта copy, залишаючи, таким чином, hello з висячим вказівником std::cout << hello.getString() << '\n'; // тут невизначені результати return 0; } |
Хоча цей код виглядає досить звичайним, але він містить одну підступну проблему, яка призведе до збою програми! Можете її виявити? Якщо ні, то нічого страшного.
Розберемо цей код по рядкам:
1 |
SomeString hello("Hello, world!"); |
Вищенаведений рядок коду не створює проблем. Тут викликається конструктор класу SomeString, який виділяє пам’ять, змушує hello.m_data
вказувати на цю пам’ять, а потім копіює у виділену адресу в пам’яті значення — рядок Hello, world!
.
1 |
SomeString copy = hello; // використовується конструктор копіювання за замовчуванням |
З цим рядком також здається, що все ок, але саме він і є джерелом нашої підступної проблеми! При обробці цього рядка C++ використовуватиме конструктор копіювання за замовчуванням (так як ми не надали свого). Виконається поверхневе копіювання, результатом чого буде ініціалізація copy.m_data
адресою, на яку вказує hello.m_data
. І тепер copy.m_data
і hello.m_data
обидва вказують на одну і ту ж частину в пам’яті!
1 |
} // об'єкт copy знищується тут |
Коли об’єкт-копія виходить з області видимості, то викликається деструктор SomeString для цієї копії. Деструктор видаляє динамічно виділену пам’ять, на яку вказують як copy.m_data
, так і hello.m_data
! Отже, видаляючи копію, ми також (випадково) видаляємо і дані hello
. Об’єкт copy
потім знищується, але hello.m_data
залишається вказувати на видалену пам’ять!
1 |
std::cout << hello.getString() << '\n'; // тут невизначені результати |
Тепер, сподіваюся, ви зрозуміли, чому ця програма працює не так, як потрібно. Ми видалили значення-рядок, на яке вказував hello
, а зараз намагаємося вивести це значення.
Коренем цієї проблеми є поверхневе копіювання, яке виконується конструктором копіювання за замовчуванням. Таке копіювання майже завжди призводить до проблем.
Глибоке копіювання
Одним з рішень цієї проблеми є виконання глибокого копіювання. При глибокому копіюванні пам’ять спочатку виділяється для копіювання адреси, яку містить вихідний вказівник, а потім для копіювання фактичного значення. Таким чином копія знаходиться в окремій, від початкового значення, пам’яті і вони ніяк не впливають одна на одну. Для виконання глибокого копіювання нам необхідно написати свій власний конструктор копіювання і перевантаження оператора присвоювання.
Розглянемо це на прикладі з класом SomeString:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Конструктор копіювання SomeString::SomeString(const SomeString& source) { // Оскільки m_length не є вказівником, то ми можемо виконати поверхневе копіювання m_length = source.m_length; // m_data є вказівником, тому нам потрібно виконати глибоке копіювання, при умові, що цей вказівник не є нульовим if (source.m_data) { // Виділяємо пам'ять для нашої копії m_data = new char[m_length]; // Виконуємо копіювання for (int i=0; i < m_length; ++i) m_data[i] = source.m_data[i]; } else m_data = 0; } |
Як бачите, реалізація тут більш поглиблена, ніж при поверхневому копіюванні! По-перше, ми повинні перевірити, чи має вихідний об’єкт нульове значення взагалі (рядок №8). Якщо має, то ми виділяємо достатньо пам’яті для зберігання копії цього значення (рядок №11). Нарешті, копіюємо значення-рядок (рядки №14-15).
Тепер розглянемо перевантаження оператора присвоювання:
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 |
// Оператор присвоювання SomeString& SomeString::operator=(const SomeString & source) { // Перевірка на самоприсвоювання if (this == &source) return *this; // Спочатку нам потрібно очистити попереднє значення m_data (члена неявного об'єкта) delete[] m_data; // Оскільки m_length не є вказівником, то ми можемо виконати поверхневе копіювання m_length = source.m_length; // m_data є вказівником, тому нам потрібно виконати глибоке копіювання, при умові, що цей вказівник не є нульовим if (source.m_data) { // Виділяємо пам'ять для нашої копії m_data = new char[m_length]; // Виконуємо копіювання for (int i=0; i < m_length; ++i) m_data[i] = source.m_data[i]; } else m_data = 0; return *this; } |
Помітили, що код перевантаження дуже схожий на код конструктора копіювання? Але тут є 3 основні відмінності:
Ми додали перевірку на самоприсвоювання.
Ми повертаємо поточний об’єкт (за допомогою вказівника *this), щоб мати можливість виконати “ланцюжок” операцій присвоювання.
Ми явно видаляємо будь-яке значення, яке вже зберігає об’єкт (щоб не відбувся витік пам’яті).
При виклику перевантаженого оператора присвоювання, об’єкт, якому присвоюється інший об’єкт, може містити попереднє значення, яке нам необхідно очистити/видалити, перш ніж ми виділимо пам’ять для нового значення. З не динамічно виділеними змінними (які мають фіксований розмір) нам не потрібно турбуватися, оскільки нове значення просто перезапише старе. Однак з динамічно виділеними змінними нам потрібно явно звільнити будь-яку стару пам’ять до того, як ми виділимо будь-яку нову пам’ять. Якщо ми цього не зробимо, збою не буде, але станеться витік пам’яті, який “з’їдатиме” нашу вільну пам’ять кожного разу, коли ми виконуватимемо операцію присвоювання!
Найкраще рішення
У Стандартній бібліотеці C++ класи, які працюють з динамічно виділеною пам’яттю, такі як std::string і std::vector, мають своє власне управління пам’яттю і свої конструктори копіювання і перевантаження операторів присвоювання, які виконують коректне глибоке копіювання. Тому, замість написання своїх власних конструкторів копіювання і перевантаження оператора присвоювання, ви можете виконувати ініціалізацію або присвоювання рядків, або векторів, як звичайних змінних фундаментальних типів даних! Це набагато простіше, менш вразливе до помилок, і вам не потрібно витрачати час на написання зайвого коду!
Висновки
Конструктор копіювання і оператор присвоювання, які надаються мовою C++ за замовчуванням, виконують поверхневе копіювання, що відмінно підходить для класів без динамічно виділених членів.
Класи з динамічно виділеними членами повинні мати конструктор копіювання і перевантаження оператора присвоювання, які виконують глибоке копіювання.
Використовуйте функціонал класів зі Стандартної бібліотеки C++ замість самостійної реалізації управління пам’яттю.