C++

Глибоке копіювання та оператор присвоювання

Shallow vs deep copy, перевантаження operator=, самоприсвоювання, Rule of Three. Правильне управління динамічною памяттю у класах.

Глибоке копіювання та оператор присвоювання

Повернення до проблеми: подвійне видалення

У статті 59 ми зіткнулися з критичною проблемою: поверхневе копіювання (shallow copy) класів із динамічною пам'яттю призводить до катастрофічних помилок. Нагадаємо проблему:

ShallowCopyProblem.cpp
#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[]
}
./ShallowCopyProblem
$ ./ShallowCopyProblem
Allocated array of size 5
array1[0] = 10
array2[0] = 10
After modifying array2:
array1[0] = 99
Deallocated array
Deallocated array
Segmentation fault (core dumped)

Що відбулося?

  1. Рядок 41: IntArray array2 = array1; викликає конструктор копіювання за замовчуванням, що робить побайтове копіювання полів: array2.data = array1.data, array2.size = array1.size
  2. Проблема: тепер array1.data і array2.data вказують на одну і ту ж ділянку пам'яті
  3. Рядок 47: зміна array2.set(0, 99) модифікує спільний масив, тому array1.get(0) теж повертає 99
  4. Рядок 51: при виході зі main() спочатку викликається ~IntArray() для array2 — звільняє пам'ять. Потім викликається ~IntArray() для array1 — намагається звільнити вже звільнену пам'ять → подвійне видалення (double free) → crash
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultTextAlignment center

rectangle "array1" as a1 {
  rectangle "data: 0x1000" as a1d
  rectangle "size: 5" as a1s
}

rectangle "array2" as a2 {
  rectangle "data: 0x1000" as a2d
  rectangle "size: 5" as a2s
}

rectangle "Heap" {
  rectangle "0x1000: [10, 20, 0, 0, 0]" as heap
}

a1d -down-> heap : вказує
a2d -down-> heap : вказує
note right of heap
  Обидва об'єкти вказують
  на одну пам'ять!
  Подвійне delete[] → crash
end note
@enduml

У цій статті ми вирішимо цю проблему раз і назавжди, реалізувавши глибоке копіювання та оператор присвоювання.


Shallow copy vs Deep copy

🪶 Shallow Copy (поверхневе)

Побайтове копіювання полів класу. Копіюються значення полів, але не дані, на які вказують покажчики.

Проблема: декілька об'єктів вказують на одну пам'ять → подвійне видалення або неочікувані модифікації.

Коли безпечно: для класів без покажчиків (класи з лише int, double, bool полями).

🏔️ Deep Copy (глибоке)

Виділення нової пам'яті та копіювання даних. Кожен об'єкт має власний блок пам'яті.

Результат: об'єкти незалежні — зміна одного не впливає на інший.

Коли потрібно: для класів із покажчиками, динамічними масивами, файловими дескрипторами тощо.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "array1" as a1 {
  rectangle "data: 0x1000" as a1d
  rectangle "size: 5" as a1s
}

rectangle "array2" as a2 {
  rectangle "data: 0x2000" as a2d
  rectangle "size: 5" as a2s
}

rectangle "Heap" {
  rectangle "0x1000: [10, 20, 0, 0, 0]" as heap1
  rectangle "0x2000: [10, 20, 0, 0, 0]" as heap2
}

a1d -down-> heap1
a2d -down-> heap2
note right of heap1
  Окремі блоки пам'яті
  Незалежні об'єкти
  Безпечне видалення
end note
@enduml

Реалізація глибокого копіювання: конструктор копіювання

Щоб забезпечити глибоке копіювання, потрібно перевантажити конструктор копіювання — спеціальний конструктор, що викликається при створенні об'єкта як копії іншого.

Сигнатура:

ClassName(const ClassName& other);

Параметр: константне посилання на об'єкт-джерело. Посилання — щоб уникнути безкінечної рекурсії (інакше копіювання параметра викликало б конструктор копіювання → копіювання параметра → ...).

DeepCopyConstructor.cpp
#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;  // ✅ Безпечне видалення: кожен об'єкт має власну пам'ять
}
./DeepCopyConstructor
$ ./DeepCopyConstructor
Constructed IntArray of size 5
Creating array2 as copy of array1...
Copy constructor called
array1[0] = 10
array2[0] = 10
After modifying array2[0] = 99:
array1[0] = 10 (unchanged!)
array2[0] = 99
Exiting main()...
Destroyed IntArray
Destroyed IntArray
Execution finished with exit code 0.

Рядки 28–40: конструктор копіювання виконує три кроки:

  1. Рядок 28: ініціалізує size значенням з other (через MIL)
  2. Рядок 33: виділяє нову пам'ять для data — це ключовий крок глибокого копіювання
  3. Рядки 36–39: копіює дані поелементно з other.data у this->data

Рядок 68: IntArray array2 = array1; — це ініціалізація, не присвоювання. Викликається конструктор копіювання, що створює array2 із власною пам'яттю.

Рядок 74: модифікація array2.set(0, 99) змінює лише array2.dataarray1.data залишається незмінним (рядок 75).

Рядки 79–80: при виході зі main() викликаються деструктори для array2 і array1 — кожен звільняє власну пам'ять. Немає подвійного видалення, немає crash.

Коли викликається конструктор копіювання?
  1. Ініціалізація об'єкта копією: IntArray a2 = a1; або IntArray a2(a1);
  2. Передача об'єкта за значенням у функцію: void foo(IntArray arr) → копіює аргумент
  3. Повернення об'єкта за значенням із функції: 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=:

  1. Перевірка самоприсвоювання — якщо this == &other, нічого не робити
  2. Звільнення старих ресурсівdelete[] поточну пам'ять
  3. Виділення нових ресурсівnew[] для копії
  4. Копіювання даних — поелементне копіювання з other
  5. Повернення *this — для ланцюгових присвоювань
AssignmentOperator.cpp
#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;
}
./AssignmentOperator
$ ./AssignmentOperator
Constructed IntArray of size 5
Constructed IntArray of size 3
Before assignment:
array1: size=5, [0]=10
array2: size=3, [0]=99
Executing: array2 = array1
Assignment operator called
After assignment:
array1: size=5, [0]=10
array2: size=5, [0]=10
After array2.set(0, 777):
array1[0] = 10 (unchanged)
array2[0] = 777
Executing: array1 = array1 (self-assignment)
Assignment operator called
Self-assignment detected, skipping
Executing: array3 = array2 = array1
Constructed IntArray of size 2
Assignment operator called
Assignment operator called
array3[0] = 10
Exiting main()...
Destroyed IntArray of size 5
Destroyed IntArray of size 5
Destroyed IntArray of size 5
Execution finished with exit code 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 виконується справа наліво:

  1. Спочатку array2 = array1 (повертає array2)
  2. Потім array3 = array2 (повертає array3)
Критична помилка: забути перевірку самоприсвоювання. Уявімо, що рядки 43–47 відсутні:
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

Застосуємо Правило трьох до класу, що керує рядком із динамічною пам'яттю:

DynamicString.cpp
#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
$ ./DynamicString
Constructed: "Hello"
"Hello" (length: 5)
Copy constructor: copying "Hello"
"Hello" (length: 5)
Constructed: "World"
"World" (length: 5)
Assignment operator: copying "Hello"
"Hello" (length: 5)
Assignment operator: copying "Hello"
"Hello" (length: 5)
Exiting main()...
Destroying: "Hello"
Destroying: "Hello"
Destroying: "Hello"
Execution finished with exit code 0.

Клас DynamicString демонструє коректне управління ресурсами: три деструктори викликаються безпечно, кожен об'єкт має власну пам'ять, немає crash.


Практичне завдання: клас Matrix

Закріпимо матеріал, реалізувавши клас Matrix для матриць довільного розміру з динамічною пам'яттю.

Вимоги:

  • Поля: double** data, int rows, int cols
  • Конструктор: виділяє двовимірний масив
  • Деструктор: звільняє двовимірний масив
  • Конструктор копіювання: глибоке копіювання
  • Оператор присвоювання: глибоке копіювання із самоприсвоюванням
  • Методи: get(r, c), set(r, c, value), print()

Альтернативи глибокому копіюванню

Глибоке копіювання вирішує проблему, але дороге: виділення пам'яті, копіювання даних. Для великих масивів це може бути повільно. Є альтернативи:

🚫 Заборона копіювання

Використовуйте = 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).

Коли: рідко модифіковані дані, часте копіювання.

Правило п'яти (Rule of Five): у C++11+ до Правила трьох додалися move constructor та move assignment. Якщо клас керує ресурсами, йому потрібні всі п'ять спеціальних функцій:
  1. Деструктор
  2. Конструктор копіювання
  3. Оператор присвоювання
  4. Move constructor (C++11)
  5. Move assignment (C++11)
Move-семантика — тема окремого розділу курсу (C++11 features).

Резюме: завершення циклу управління ресурсами

У цій статті ми вирішили проблему, що виникла у статті 59, і завершили вивчення базових механізмів ООП у C++.

Ключові концепції:

  1. Shallow copy — побайтове копіювання полів, небезпечне для покажчиків
  2. Deep copy — виділення нової пам'яті та копіювання даних
  3. Конструктор копіюванняClassName(const ClassName&), викликається при ініціалізації
  4. Оператор присвоюванняoperator=(const ClassName&), викликається при присвоюванні
  5. Самоприсвоювання — перевірка if (this == &other) критична для коректності
  6. Правило трьох — деструктор + конструктор копіювання + operator= йдуть разом
Практична порада: у сучасному C++ (після C++11) замість ручного управління пам'яттю використовуйте смарт-покажчики (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 статтях, — фундамент для всього, що буде далі. Вітаю з завершенням цього етапу! 🎉

Copyright © 2026