Глибоке копіювання та оператор присвоювання
Глибоке копіювання та оператор присвоювання
Повернення до проблеми: подвійне видалення
У статті 59 ми зіткнулися з критичною проблемою: поверхневе копіювання (shallow copy) класів із динамічною пам'яттю призводить до катастрофічних помилок. Нагадаємо проблему:
#include <iostream>
class IntArray
{
private:
int* data;
int size;
public:
IntArray(int s) : size(s)
{
data = new int[size];
std::cout << "Allocated array of size " << size << "\n";
}
~IntArray()
{
delete[] data;
std::cout << "Deallocated array\n";
}
void set(int index, int value)
{
data[index] = value;
}
int get(int index) const
{
return data[index];
}
};
int main()
{
IntArray array1(5);
array1.set(0, 10);
array1.set(1, 20);
// Копіювання: компілятор створює shallow copy
IntArray array2 = array1;
std::cout << "array1[0] = " << array1.get(0) << "\n";
std::cout << "array2[0] = " << array2.get(0) << "\n";
// Модифікація array2 впливає на array1!
array2.set(0, 99);
std::cout << "After modifying array2:\n";
std::cout << "array1[0] = " << array1.get(0) << "\n"; // ⚠️ Теж 99!
return 0; // ☠️ CRASH при виході: подвійне delete[]
}
Що відбулося?
- Рядок 41:
IntArray array2 = array1;викликає конструктор копіювання за замовчуванням, що робить побайтове копіювання полів:array2.data = array1.data,array2.size = array1.size - Проблема: тепер
array1.dataіarray2.dataвказують на одну і ту ж ділянку пам'яті - Рядок 47: зміна
array2.set(0, 99)модифікує спільний масив, томуarray1.get(0)теж повертає 99 - Рядок 51: при виході зі
main()спочатку викликається~IntArray()дляarray2— звільняє пам'ять. Потім викликається~IntArray()дляarray1— намагається звільнити вже звільнену пам'ять → подвійне видалення (double free) → crash
У цій статті ми вирішимо цю проблему раз і назавжди, реалізувавши глибоке копіювання та оператор присвоювання.
Shallow copy vs Deep copy
🪶 Shallow Copy (поверхневе)
Побайтове копіювання полів класу. Копіюються значення полів, але не дані, на які вказують покажчики.
Проблема: декілька об'єктів вказують на одну пам'ять → подвійне видалення або неочікувані модифікації.
Коли безпечно: для класів без покажчиків (класи з лише int, double, bool полями).
🏔️ Deep Copy (глибоке)
Виділення нової пам'яті та копіювання даних. Кожен об'єкт має власний блок пам'яті.
Результат: об'єкти незалежні — зміна одного не впливає на інший.
Коли потрібно: для класів із покажчиками, динамічними масивами, файловими дескрипторами тощо.
Реалізація глибокого копіювання: конструктор копіювання
Щоб забезпечити глибоке копіювання, потрібно перевантажити конструктор копіювання — спеціальний конструктор, що викликається при створенні об'єкта як копії іншого.
Сигнатура:
ClassName(const ClassName& other);
Параметр: константне посилання на об'єкт-джерело. Посилання — щоб уникнути безкінечної рекурсії (інакше копіювання параметра викликало б конструктор копіювання → копіювання параметра → ...).
#include <iostream>
class IntArray
{
private:
int* data;
int size;
public:
IntArray(int s) : size(s)
{
data = new int[size];
for (int i = 0; i < size; ++i)
{
data[i] = 0;
}
std::cout << "Constructed IntArray of size " << size << "\n";
}
// Деструктор
~IntArray()
{
delete[] data;
std::cout << "Destroyed IntArray\n";
}
// Конструктор глибокого копіювання
IntArray(const IntArray& other) : size(other.size)
{
std::cout << "Copy constructor called\n";
// Виділяємо НОВУ пам'ять
data = new int[size];
// Копіюємо дані поелементно
for (int i = 0; i < size; ++i)
{
data[i] = other.data[i];
}
}
void set(int index, int value)
{
data[index] = value;
}
int get(int index) const
{
return data[index];
}
int getSize() const
{
return size;
}
};
int main()
{
IntArray array1(5);
array1.set(0, 10);
array1.set(1, 20);
array1.set(2, 30);
std::cout << "\nCreating array2 as copy of array1...\n";
IntArray array2 = array1; // Викликається конструктор копіювання
std::cout << "\narray1[0] = " << array1.get(0) << "\n";
std::cout << "array2[0] = " << array2.get(0) << "\n";
// Модифікація array2 не впливає на array1
array2.set(0, 99);
std::cout << "\nAfter modifying array2[0] = 99:\n";
std::cout << "array1[0] = " << array1.get(0) << " (unchanged!)\n";
std::cout << "array2[0] = " << array2.get(0) << "\n";
std::cout << "\nExiting main()...\n";
return 0; // ✅ Безпечне видалення: кожен об'єкт має власну пам'ять
}
Рядки 28–40: конструктор копіювання виконує три кроки:
- Рядок 28: ініціалізує
sizeзначенням зother(через MIL) - Рядок 33: виділяє нову пам'ять для
data— це ключовий крок глибокого копіювання - Рядки 36–39: копіює дані поелементно з
other.dataуthis->data
Рядок 68: IntArray array2 = array1; — це ініціалізація, не присвоювання. Викликається конструктор копіювання, що створює array2 із власною пам'яттю.
Рядок 74: модифікація array2.set(0, 99) змінює лише array2.data — array1.data залишається незмінним (рядок 75).
Рядки 79–80: при виході зі main() викликаються деструктори для array2 і array1 — кожен звільняє власну пам'ять. Немає подвійного видалення, немає crash.
- Ініціалізація об'єкта копією:
IntArray a2 = a1;абоIntArray a2(a1); - Передача об'єкта за значенням у функцію:
void foo(IntArray arr)→ копіює аргумент - Повернення об'єкта за значенням із функції:
IntArray bar() { return arr; }→ копіюєarr
Оператор присвоювання: operator=
Конструктор копіювання вирішує проблему ініціалізації (IntArray a2 = a1), але не вирішує проблему присвоювання вже існуючому об'єкту:
IntArray a1(5);
IntArray a2(10); // a2 вже існує з власною пам'яттю
a2 = a1; // ❓ Що відбувається тут?
Для цього потрібно перевантажити оператор присвоювання operator=.
Сигнатура:
ClassName& operator=(const ClassName& other);
Повернення: посилання на *this для підтримки ланцюгових присвоювань (a = b = c).
Алгоритм operator=:
- Перевірка самоприсвоювання — якщо
this == &other, нічого не робити - Звільнення старих ресурсів —
delete[]поточну пам'ять - Виділення нових ресурсів —
new[]для копії - Копіювання даних — поелементне копіювання з
other - Повернення
*this— для ланцюгових присвоювань
#include <iostream>
class IntArray
{
private:
int* data;
int size;
public:
IntArray(int s) : size(s)
{
data = new int[size];
for (int i = 0; i < size; ++i)
{
data[i] = 0;
}
std::cout << "Constructed IntArray of size " << size << "\n";
}
~IntArray()
{
delete[] data;
std::cout << "Destroyed IntArray of size " << size << "\n";
}
// Конструктор копіювання (глибоке копіювання)
IntArray(const IntArray& other) : size(other.size)
{
std::cout << "Copy constructor called\n";
data = new int[size];
for (int i = 0; i < size; ++i)
{
data[i] = other.data[i];
}
}
// Оператор присвоювання (глибоке копіювання)
IntArray& operator=(const IntArray& other)
{
std::cout << "Assignment operator called\n";
// Крок 1: Перевірка самоприсвоювання
if (this == &other)
{
std::cout << "Self-assignment detected, skipping\n";
return *this;
}
// Крок 2: Звільнення старої пам'яті
delete[] data;
// Крок 3: Виділення нової пам'яті
size = other.size;
data = new int[size];
// Крок 4: Копіювання даних
for (int i = 0; i < size; ++i)
{
data[i] = other.data[i];
}
// Крок 5: Повернення посилання на себе
return *this;
}
void set(int index, int value)
{
data[index] = value;
}
int get(int index) const
{
return data[index];
}
int getSize() const
{
return size;
}
};
int main()
{
IntArray array1(5);
array1.set(0, 10);
array1.set(1, 20);
IntArray array2(3); // array2 вже існує з size=3
array2.set(0, 99);
std::cout << "\nBefore assignment:\n";
std::cout << "array1: size=" << array1.getSize() << ", [0]=" << array1.get(0) << "\n";
std::cout << "array2: size=" << array2.getSize() << ", [0]=" << array2.get(0) << "\n";
// Присвоювання: викликається operator=
std::cout << "\nExecuting: array2 = array1\n";
array2 = array1;
std::cout << "\nAfter assignment:\n";
std::cout << "array1: size=" << array1.getSize() << ", [0]=" << array1.get(0) << "\n";
std::cout << "array2: size=" << array2.getSize() << ", [0]=" << array2.get(0) << "\n";
// Модифікація array2 не впливає на array1
array2.set(0, 777);
std::cout << "\nAfter array2.set(0, 777):\n";
std::cout << "array1[0] = " << array1.get(0) << " (unchanged)\n";
std::cout << "array2[0] = " << array2.get(0) << "\n";
// Самоприсвоювання
std::cout << "\nExecuting: array1 = array1 (self-assignment)\n";
array1 = array1;
// Ланцюгове присвоювання
IntArray array3(2);
std::cout << "\nExecuting: array3 = array2 = array1\n";
array3 = array2 = array1; // Викликається двічі: array2 = array1, array3 = array2
std::cout << "array3[0] = " << array3.get(0) << "\n";
std::cout << "\nExiting main()...\n";
return 0;
}
Рядки 43–47: перевірка самоприсвоювання — якщо this == &other, об'єкт присвоюється сам собі (a = a). У такому випадку нічого не робимо, одразу повертаємо *this.
Чому це важливо? Без цієї перевірки крок 2 звільнив би пам'ять, а крок 4 намагався б копіювати зі звільненої пам'яті → undefined behavior.
Рядок 51: звільняємо стару пам'ять array2 (розмір 3). Це критично: інакше витік пам'яті.
Рядки 54–55: виділяємо нову пам'ять розміру other.size (тепер array2 має розмір 5, як array1).
Рядки 58–61: копіюємо дані поелементно.
Рядок 65: повертаємо *this — посилання на поточний об'єкт. Це дозволяє ланцюгові присвоювання.
Рядок 100: array2 = array1 — викликається operator=, що звільняє стару пам'ять array2, виділяє нову і копіює дані з array1.
Рядок 116: самоприсвоювання array1 = array1 — перевірка на рядку 43 спрацьовує, нічого не відбувається.
Рядок 121: ланцюгове присвоювання array3 = array2 = array1 виконується справа наліво:
- Спочатку
array2 = array1(повертаєarray2) - Потім
array3 = array2(повертаєarray3)
IntArray& operator=(const IntArray& other)
{
delete[] data; // Звільняємо пам'ять
size = other.size;
data = new int[size];
// Копіюємо з other.data — але якщо this == &other,
// то other.data вже видалений! Undefined behavior!
for (int i = 0; i < size; ++i)
data[i] = other.data[i]; // ☠️ Копіювання зі звільненої пам'яті
return *this;
}
a = a цей код спочатку видаляє a.data, потім намагається скопіювати з a.data (що вже не існує). Це призведе до crash або мусору.Правило трьох (Rule of Three)
Якщо ваш клас потребує хоча б одного з цих трьох, він майже напевно потребує всіх трьох:
Деструктор ~ClassName()
Звільняє ресурси (пам'ять, файли, з'єднання). Якщо клас керує динамічною пам'яттю, деструктор обов'язковий.
Конструктор копіювання ClassName(const ClassName&)
Забезпечує глибоке копіювання ресурсів. Без нього компілятор створює shallow copy, що призводить до подвійного видалення.
Оператор присвоювання operator=(const ClassName&)
Забезпечує глибоке копіювання при присвоюванні. Без нього присвоювання створює shallow copy.
Приклад порушення:
class Broken
{
private:
int* data;
public:
Broken(int size) { data = new int[size]; }
~Broken() { delete[] data; } // ✅ Є деструктор
// ❌ Немає конструктора копіювання → shallow copy → crash
// ❌ Немає operator= → shallow copy → crash
};
int main()
{
Broken b1(10);
Broken b2 = b1; // ☠️ Shallow copy → подвійне видалення при виході
return 0;
}
Повний приклад: клас DynamicString
Застосуємо Правило трьох до класу, що керує рядком із динамічною пам'яттю:
#include <iostream>
#include <cstring>
class DynamicString
{
private:
char* text;
int length;
public:
// Конструктор
DynamicString(const char* str = "")
{
length = std::strlen(str);
text = new char[length + 1];
std::strcpy(text, str);
std::cout << "Constructed: \"" << text << "\"\n";
}
// Деструктор
~DynamicString()
{
std::cout << "Destroying: \"" << text << "\"\n";
delete[] text;
}
// Конструктор копіювання (глибоке копіювання)
DynamicString(const DynamicString& other)
{
std::cout << "Copy constructor: copying \"" << other.text << "\"\n";
length = other.length;
text = new char[length + 1];
std::strcpy(text, other.text);
}
// Оператор присвоювання (глибоке копіювання)
DynamicString& operator=(const DynamicString& other)
{
std::cout << "Assignment operator: copying \"" << other.text << "\"\n";
// Самоприсвоювання
if (this == &other)
{
return *this;
}
// Звільнити старе
delete[] text;
// Виділити нове і скопіювати
length = other.length;
text = new char[length + 1];
std::strcpy(text, other.text);
return *this;
}
// Геттери
const char* getText() const { return text; }
int getLength() const { return length; }
// Вивід
void print() const
{
std::cout << "\"" << text << "\" (length: " << length << ")";
}
};
int main()
{
DynamicString s1("Hello");
s1.print();
std::cout << "\n\n";
// Конструктор копіювання
DynamicString s2 = s1;
s2.print();
std::cout << "\n\n";
// Оператор присвоювання
DynamicString s3("World");
s3.print();
std::cout << "\n";
s3 = s1; // s3 тепер копія s1
s3.print();
std::cout << "\n\n";
// Самоприсвоювання
s1 = s1;
s1.print();
std::cout << "\n\n";
std::cout << "Exiting main()...\n";
return 0;
}
Клас DynamicString демонструє коректне управління ресурсами: три деструктори викликаються безпечно, кожен об'єкт має власну пам'ять, немає crash.
Практичне завдання: клас Matrix
Закріпимо матеріал, реалізувавши клас Matrix для матриць довільного розміру з динамічною пам'яттю.
Вимоги:
- Поля:
double** data,int rows,int cols - Конструктор: виділяє двовимірний масив
- Деструктор: звільняє двовимірний масив
- Конструктор копіювання: глибоке копіювання
- Оператор присвоювання: глибоке копіювання із самоприсвоюванням
- Методи:
get(r, c),set(r, c, value),print()
Двовимірний масив у динамічній пам'яті:
// Виділення
data = new double*[rows]; // Масив покажчиків на рядки
for (int i = 0; i < rows; ++i)
{
data[i] = new double[cols]; // Кожен рядок — масив double
}
// Звільнення
for (int i = 0; i < rows; ++i)
{
delete[] data[i]; // Спочатку звільнити кожен рядок
}
delete[] data; // Потім масив покажчиків
#include <iostream>
class Matrix
{
private:
double** data;
int rows;
int cols;
public:
// Конструктор
Matrix(int r, int c) : rows(r), cols(c)
{
data = new double*[rows];
for (int i = 0; i < rows; ++i)
{
data[i] = new double[cols];
for (int j = 0; j < cols; ++j)
{
data[i][j] = 0.0;
}
}
std::cout << "Constructed Matrix " << rows << "x" << cols << "\n";
}
// Деструктор
~Matrix()
{
for (int i = 0; i < rows; ++i)
{
delete[] data[i];
}
delete[] data;
std::cout << "Destroyed Matrix " << rows << "x" << cols << "\n";
}
// Конструктор копіювання (глибоке копіювання)
Matrix(const Matrix& other) : rows(other.rows), cols(other.cols)
{
std::cout << "Copy constructor: Matrix " << rows << "x" << cols << "\n";
data = new double*[rows];
for (int i = 0; i < rows; ++i)
{
data[i] = new double[cols];
for (int j = 0; j < cols; ++j)
{
data[i][j] = other.data[i][j];
}
}
}
// Оператор присвоювання (глибоке копіювання)
Matrix& operator=(const Matrix& other)
{
std::cout << "Assignment operator: Matrix " << other.rows << "x" << other.cols << "\n";
// Самоприсвоювання
if (this == &other)
{
return *this;
}
// Звільнити стару пам'ять
for (int i = 0; i < rows; ++i)
{
delete[] data[i];
}
delete[] data;
// Виділити нову пам'ять
rows = other.rows;
cols = other.cols;
data = new double*[rows];
for (int i = 0; i < rows; ++i)
{
data[i] = new double[cols];
for (int j = 0; j < cols; ++j)
{
data[i][j] = other.data[i][j];
}
}
return *this;
}
// Геттери та сеттери
double get(int r, int c) const
{
return data[r][c];
}
void set(int r, int c, double value)
{
data[r][c] = value;
}
int getRows() const { return rows; }
int getCols() const { return cols; }
// Вивід
void print() const
{
for (int i = 0; i < rows; ++i)
{
std::cout << "[ ";
for (int j = 0; j < cols; ++j)
{
std::cout << data[i][j];
if (j < cols - 1) std::cout << ", ";
}
std::cout << " ]\n";
}
}
};
int main()
{
Matrix m1(2, 3);
m1.set(0, 0, 1.0);
m1.set(0, 1, 2.0);
m1.set(0, 2, 3.0);
m1.set(1, 0, 4.0);
m1.set(1, 1, 5.0);
m1.set(1, 2, 6.0);
std::cout << "\nMatrix m1:\n";
m1.print();
// Конструктор копіювання
std::cout << "\nCreating m2 as copy of m1...\n";
Matrix m2 = m1;
std::cout << "Matrix m2:\n";
m2.print();
// Модифікація m2 не впливає на m1
m2.set(0, 0, 99.0);
std::cout << "\nAfter m2.set(0, 0, 99):\n";
std::cout << "m1[0][0] = " << m1.get(0, 0) << " (unchanged)\n";
std::cout << "m2[0][0] = " << m2.get(0, 0) << "\n";
// Оператор присвоювання
std::cout << "\nCreating m3...\n";
Matrix m3(1, 1);
std::cout << "\nExecuting: m3 = m1\n";
m3 = m1;
std::cout << "Matrix m3:\n";
m3.print();
std::cout << "\nExiting main()...\n";
return 0;
}
Пояснення:
- Рядки 14–22: конструктор виділяє масив покажчиків (
new double*[rows]), потім для кожного рядка виділяє масив (new double[cols]). Двовимірний масив у пам'яті. - Рядки 27–34: деструктор звільняє у зворотному порядку: спочатку кожен рядок (
delete[] data[i]), потім масив покажчиків (delete[] data). - Рядки 38–51: конструктор копіювання виділяє нову пам'ять і копіює дані поелементно.
- Рядки 54–86:
operator=звільняє стару пам'ять, виділяє нову (навіть якщо розмір змінився:m3був 1×1, став 2×3), копіює дані. - Рядок 147: модифікація
m2не впливає наm1— глибоке копіювання працює.
Альтернативи глибокому копіюванню
Глибоке копіювання вирішує проблему, але дороге: виділення пам'яті, копіювання даних. Для великих масивів це може бути повільно. Є альтернативи:
🚫 Заборона копіювання
Використовуйте = delete для конструктора копіювання та operator=:
class UniqueArray
{
public:
UniqueArray(const UniqueArray&) = delete;
UniqueArray& operator=(const UniqueArray&) = delete;
};
Коли: для класів із унікальними ресурсами (FileHandle, Mutex).
🔀 Move-семантика
C++11 додав move constructor та move assignment, що переміщують ресурси замість копіювання:
class IntArray
{
public:
// Move constructor
IntArray(IntArray&& other) noexcept;
// Move assignment
IntArray& operator=(IntArray&& other) noexcept;
};
Коли: для тимчасових об'єктів, повернення з функцій. Набагато швидше за копіювання.
📊 Лічильник посилань
Кілька об'єктів вказують на одні дані, лічильник відстежує кількість. Звільняє пам'ять, коли лічильник = 0. Використовується у std::shared_ptr.
Коли: для великих даних, що активно шаряться між об'єктами.
💡 Copy-on-write
Об'єкти шарять дані до першої модифікації. При спробі зміни створюється копія. Використовується у деяких реалізаціях std::string (до C++11).
Коли: рідко модифіковані дані, часте копіювання.
- Деструктор
- Конструктор копіювання
- Оператор присвоювання
- Move constructor (C++11)
- Move assignment (C++11)
Резюме: завершення циклу управління ресурсами
У цій статті ми вирішили проблему, що виникла у статті 59, і завершили вивчення базових механізмів ООП у C++.
Ключові концепції:
- Shallow copy — побайтове копіювання полів, небезпечне для покажчиків
- Deep copy — виділення нової пам'яті та копіювання даних
- Конструктор копіювання —
ClassName(const ClassName&), викликається при ініціалізації - Оператор присвоювання —
operator=(const ClassName&), викликається при присвоюванні - Самоприсвоювання — перевірка
if (this == &other)критична для коректності - Правило трьох — деструктор + конструктор копіювання + operator= йдуть разом
std::unique_ptr, std::shared_ptr) та контейнери STL (std::vector, std::string). Вони автоматично реалізують Правило п'яти і захищають від витоків пам'яті. Але розуміння як це працює всередині критично важливе для професійного C++ програміста.Що далі: від базового ООП до розширених тем
Ми завершили серію з 18 статей про об'єктно-орієнтоване програмування у C++. Пройшли шлях від мотивації ООП (стаття 49) через механізми класів (статті 50–60) до розширених технік перевантаження операторів та управління пам'яттю (статті 61–66).
Що ви тепер вмієте:
- Створювати класи з інкапсуляцією, конструкторами, деструкторами
- Використовувати покажчик
this, статичні члени, friend-функції - Перевантажувати оператори для природного синтаксису
- Контролювати конвертації через
explicitтаdelete - Коректно управляти динамічною пам'яттю через глибоке копіювання
Наступні теми курсу C++:
- Наслідування та поліморфізм — другий і третій стовпи ООП, віртуальні функції, абстрактні класи
- Шаблони (templates) — generic programming,
template<typename T>, STL під капотом - Обробка помилок — винятки (exceptions), RAII та exception safety
- C++11/14/17/20 features — auto, range-based for, lambda, move-семантика, smart pointers, constexpr
Але це вже теми окремих серій. Базові механізми ООП, що ви вивчили у цих 18 статтях, — фундамент для всього, що буде далі. Вітаю з завершенням цього етапу! 🎉