C++

Конструктор копіювання та запобігання копіюванню

Коли C++ копіює об'єкти, неявний конструктор копіювання, сигнатура та семантика, три сценарії копіювання (ініціалізація, передача за значенням, повернення), проблема shallow copy із динамічною пам'яттю, заборона копіювання через = delete.

Конструктор копіювання та запобігання копіюванню

Коли C++ копіює об'єкти

До цього моменту ми створювали об'єкти класів через звичайні конструктори — параметризовані чи за замовчуванням. Але C++ автоматично копіює об'єкти у кількох ситуаціях, навіть якщо ви не пишете явного коду копіювання:

Date today(16, 9, 2026);
Date copy = today;  // ← Копіювання!

Здавалося б, проста ініціалізація. Але що відбувається насправді? Створюється новий об'єкт copy, і його поля заповнюються значеннями з об'єкта today. Це копіювання об'єкта.

Копіювання відбувається не лише при ініціалізації. Розглянемо три ключові сценарії:

Сценарій 1: ініціалізація об'єкта іншим об'єктом

Date birthday(5, 12, 2005);
Date anotherDate = birthday;  // Копіювання
Date yetAnother(birthday);     // Теж копіювання

Обидва anotherDate і yetAnother — це нові об'єкти, незалежні від birthday. Зміна одного не впливає на інший.

Сценарій 2: передача об'єкта у функцію за значенням

void printDate(Date d)  // Параметр передається за значенням
{
    d.print();
}

int main()
{
    Date today(16, 9, 2026);
    printDate(today);  // Копіювання today → d
}

Функція printDate приймає параметр Date d за значенням. Це означає створення локальної копії об'єкта today. Всередині функції працює копія, а оригінал залишається незмінним.

Сценарій 3: повернення об'єкта з функції за значенням

Date createDate()
{
    Date temp(1, 1, 2025);
    return temp;  // Копіювання temp → результат виклику
}

int main()
{
    Date d = createDate();  // Копіювання результату → d
}

Функція повертає об'єкт temp за значенням. Компілятор створює копію цього об'єкта для передачі назовні функції.

Сучасні компілятори оптимізують копіювання через RVO (Return Value Optimization) та NRVO (Named RVO), часто повністю уникаючи зайвих копій. Але семантично копіювання все одно відбувається — просто оптимізується.

Неявний конструктор копіювання

У всіх трьох сценаріях вище ми не писали жодного коду для копіювання об'єктів. Звідки C++ знає, як копіювати Date?

Відповідь: якщо ви не визначите конструктор копіювання явно, компілятор автоматично згенерує його за вас. Цей неявний конструктор копіює кожне поле об'єкта побайтово (memberwise copy) у відповідне поле нового об'єкта.

class Date
{
private:
    int day;
    int month;
    int year;

public:
    Date(int d, int m, int y) : day(d), month(m), year(y) {}

    // Компілятор автоматично генерує еквівалент:
    // Date(const Date& other)
    //     : day(other.day), month(other.month), year(other.year)
    // {}
};

Для простих класів (лише POD-поля: int, double, char тощо) неявний конструктор копіювання працює ідеально. Він копіює всі значення, створюючи незалежну копію об'єкта.

POD (Plain Old Data) — типи даних, сумісні з C: примітивні типи, структури без конструкторів/деструкторів, масиви таких типів. Для них побайтове копіювання безпечне.

Явний конструктор копіювання: синтаксис

Конструктор копіювання — це спеціальний конструктор із строго визначеною сигнатурою:

ClassName(const ClassName& other);

Розбір сигнатури:

  • ClassName — ім'я класу (як і у всіх конструкторів)
  • const ClassName& — параметр типу «константне посилання на об'єкт того ж класу»
  • other (або source, copy) — ім'я параметра (будь-яке на ваш вибір)

Ключові деталі:

  1. Посилання обов'язкове: параметр має бути посиланням (&). Якби він був значенням (ClassName other), виклик конструктора копіювання сам викликав би конструктор копіювання для створення копії параметра — безкінечна рекурсія!
  2. const рекомендовано: вихідний об'єкт не має змінюватися при копіюванні. const гарантує це на рівні компілятора.

Приклад: клас Fraction

Fraction.cpp
#include <iostream>

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    // Звичайний конструктор
    Fraction(int num = 0, int den = 1) : numerator(num), denominator(den)
    {
        if (denominator == 0) {
            denominator = 1;
        }
        std::cout << "Звичайний конструктор: " << numerator << "/" << denominator << "\n";
    }

    // Конструктор копіювання
    Fraction(const Fraction& other)
        : numerator(other.numerator), denominator(other.denominator)
    {
        std::cout << "Конструктор копіювання: " << numerator << "/" << denominator << "\n";
    }

    void print() const
    {
        std::cout << numerator << "/" << denominator;
    }
};

int main()
{
    Fraction f1(3, 4);          // Звичайний конструктор
    Fraction f2 = f1;           // Конструктор копіювання
    Fraction f3(f1);            // Конструктор копіювання
    
    std::cout << "f1: "; f1.print(); std::cout << "\n";
    std::cout << "f2: "; f2.print(); std::cout << "\n";
    std::cout << "f3: "; f3.print(); std::cout << "\n";

    return 0;
}
./program
$ ./program
Звичайний конструктор: 3/4
Конструктор копіювання: 3/4
Конструктор копіювання: 3/4
f1: 3/4
f2: 3/4
f3: 3/4
Execution finished with exit code 0.

Що відбувається

Рядок 31: Fraction f1(3, 4); — виклик звичайного конструктора.

Рядок 32: Fraction f2 = f1; — це не оператор присвоювання! Це копіююча ініціалізація (copy initialization). Створюється новий об'єкт f2, викликається конструктор копіювання.

Рядок 33: Fraction f3(f1);пряма ініціалізація (direct initialization) через конструктор копіювання.

Обидва синтаксиси (= obj та (obj)) викликають конструктор копіювання.


Три форми копіювання: детальний розбір

Повернемося до трьох сценаріїв копіювання і розглянемо їх детальніше.

1. Ініціалізація об'єкта іншим об'єктом

Fraction original(1, 2);
Fraction copy1 = original;  // Копіююча ініціалізація
Fraction copy2(original);   // Пряма ініціалізація
Fraction copy3{original};   // Uniform-ініціалізація (C++11)

Усі три форми викликають конструктор копіювання. Різниця лише синтаксична.

Fraction copy = original; — це не те саме, що copy = original; після оголошення. Перше — виклик конструктора, друге — виклик оператора присвоювання (розглянемо у статті 66).

2. Передача за значенням

void processFraction(Fraction f)  // Копіювання аргументу
{
    f.print();
    // Тут працює локальна копія
}  // Копія знищується при виході зі scope

int main()
{
    Fraction original(5, 6);
    processFraction(original);  // Копіювання original → f
}

Процес:

  1. Виклик processFraction(original)
  2. Параметр f — локальна змінна функції
  3. Викликається конструктор копіювання: Fraction f(original)
  4. Функція працює з копією
  5. При виході з функції викликається деструктор копії

Якщо об'єкт великий (багато полів, динамічна пам'ять), копіювання може бути дорогим. Тому для великих об'єктів використовують передачу по посиланню (const Fraction&).

3. Повернення за значенням

Fraction createFraction()
{
    Fraction temp(7, 8);
    return temp;  // Копіювання temp → результат
}

int main()
{
    Fraction result = createFraction();  // Копіювання результату → result
}

Теоретично відбувається два копіювання:

  1. З локальної змінної temp у тимчасовий об'єкт результату функції
  2. З тимчасового об'єкта у змінну result

На практиці сучасні компілятори оптимізують це до нуля копій через RVO (Return Value Optimization). Але семантично конструктор копіювання має бути доступним.

Якщо функція повертає об'єкт, майже завжди компілятор виконає RVO — реальне копіювання не відбудеться. Але якщо конструктор копіювання видалений (= delete), код не скомпілюється, навіть якщо копіювання теоретично можна уникнути.

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

Для простих класів із POD-полями неявний конструктор працює ідеально. Але що, якщо клас має динамічну пам'ять?

Проблема: shallow copy (поверхневе копіювання)

IntArray.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 << "Масив створено: " << data << "\n";
    }

    ~IntArray()
    {
        std::cout << "Видалення масиву: " << data << "\n";
        delete[] data;
    }

    void set(int index, int value)
    {
        if (index >= 0 && index < size) {
            data[index] = value;
        }
    }

    void print() const
    {
        for (int i = 0; i < size; i++) {
            std::cout << data[i] << " ";
        }
        std::cout << "\n";
    }
};

int main()
{
    IntArray arr1(3);
    arr1.set(0, 10);
    arr1.set(1, 20);
    arr1.set(2, 30);

    std::cout << "arr1: ";
    arr1.print();

    {
        IntArray arr2 = arr1;  // ⚠️ Небезпечне копіювання!
        std::cout << "arr2: ";
        arr2.print();
    }  // arr2 виходить зі scope → деструктор викликається

    std::cout << "arr1 після знищення arr2: ";
    arr1.print();  // ☠️ Висячий покажчик!

    return 0;
}
./program (можлива помилка)
$ ./program
Масив створено: 0x7f8e3c4058a0
arr1: 10 20 30
arr2: 10 20 30
Видалення масиву: 0x7f8e3c4058a0
arr1 після знищення arr2: Segmentation fault (core dumped)

Що пішло не так

Неявний конструктор копіювання:

IntArray(const IntArray& other)
    : data(other.data),  // ⚠️ Копіюється адреса!
      size(other.size)
{}

Поле data — це покажчик. Неявний конструктор копіює адресу, а не дані, на які вона вказує. Результат:

arr1.data ──┐
            ├──► [10, 20, 30] (один масив у купі)
arr2.data ──┘

Обидва об'єкти вказують на один масив. Коли arr2 виходить зі scope, його деструктор викликає delete[] data — масив видаляється. Тепер arr1.data вказує на вже звільнену пам'ять — висячий покажчик (dangling pointer). Спроба використати arr1 призводить до crash.

Коли деструктор arr1 спробує знову викликати delete[] data — це подвійне видалення (double delete), що гарантовано призводить до undefined behavior.

Shallow copy безпечний лише для класів без покажчиків та інших ресурсів. Для класів із динамічною пам'яттю, файловими дескрипторами, мережевими з'єднаннями потрібен deep copy — тема наступної статті (66).

Заборона копіювання: = delete

Іноді логіка класу забороняє копіювання. Наприклад:

  • Унікальні ресурси: файловий дескриптор, мережеве з'єднання, м'ютекс — їх не можна просто скопіювати
  • Класи-обгортки: клас, що керує єдиним екземпляром ресурсу (singleton pattern)
  • Класи з дорогим копіюванням: якщо копіювання завжди помилка, краще заборонити на рівні компілятора

У C++11 і новіше для заборони копіювання використовується = delete:

class ClassName
{
public:
    ClassName(const ClassName& other) = delete;  // Копіювання заборонено
};

Приклад: клас FileHandle

FileHandle.cpp
#include <iostream>
#include <fstream>
#include <string>

class FileHandle
{
private:
    std::ofstream file;
    std::string filename;

public:
    FileHandle(const std::string& name) : filename(name)
    {
        file.open(filename);
        if (file.is_open()) {
            std::cout << "Файл " << filename << " відкрито\n";
        }
    }

    ~FileHandle()
    {
        if (file.is_open()) {
            file.close();
            std::cout << "Файл " << filename << " закрито\n";
        }
    }

    // Заборона копіювання
    FileHandle(const FileHandle& other) = delete;

    void write(const std::string& text)
    {
        if (file.is_open()) {
            file << text << "\n";
        }
    }
};

int main()
{
    FileHandle log("app.log");
    log.write("Програма запущена");

    // FileHandle copy = log;  // ❌ Помилка компіляції!

    return 0;
}
./program
$ ./program
Файл app.log відкрито
Файл app.log закрито
Execution finished with exit code 0.

Спроба розкоментувати FileHandle copy = log; призведе до помилки компіляції:

error: use of deleted function 'FileHandle::FileHandle(const FileHandle&)'

Компілятор не дозволяє скомпілювати код, що намагається скопіювати об'єкт.

Чому = delete, а не приватний конструктор?

У класичному C++ (до C++11) копіювання забороняли, оголошуючи конструктор копіювання у секції private: без визначення:

class OldWay
{
private:
    OldWay(const OldWay& other);  // Оголошено, але не визначено

public:
    // ...
};

Спроба копіювання ззовні класу призводила до помилки доступу, спроба копіювання всередині класу (методом-членом) — до помилки лінкера.

Недоліки старого способу:

  • Повідомлення компілятора менш зрозумілі («private constructor» замість «deleted function»)
  • Помилка виявляється пізніше (на етапі лінкування, якщо копіювання з методу класу)
  • Не працює з шаблонами та SFINAE

= delete краще:

  • Явна заява: «метод видалений, не можна використовувати»
  • Помилка компіляції з чітким повідомленням
  • Працює з шаблонами та перевантаженням
У сучасному C++ (C++11+) завжди використовуйте = delete для заборони копіювання, а не приватний конструктор.

Копіювання vs присвоювання: різниця

Важливо розрізняти два поняття, які легко сплутати:

Fraction f1(3, 4);
Fraction f2 = f1;  // ← Конструктор копіювання

Копіювання — створення нового об'єкта на основі існуючого. Викликається конструктор копіювання.

Присвоювання — зміна існуючого об'єкта значеннями іншого. Викликається оператор присвоювання operator= (тема статті 66).

Ключова різниця:

АспектКонструктор копіюванняОператор присвоювання
Коли викликаєтьсяПри створенні нового об'єктаПісля створення, при присвоюванні
Об'єкт до викликуНе існуєВже існує
СинтаксисType obj = other; або Type obj(other);obj = other;
Тип методуКонструкторОператор
Fraction f1(3, 4);

Fraction f2 = f1;  // Конструктор копіювання — f2 ще не існує
Fraction f3(1, 2); // Звичайний конструктор — f3 створюється
f3 = f1;           // Оператор присвоювання — f3 вже існує
Синтаксис Fraction f2 = f1; виглядає як присвоювання, але це копіювання — об'єкт f2 ще не існував до цього рядка. Компілятор викликає конструктор, не оператор.

Практичний приклад: клас String (спрощений)

Розглянемо спрощений клас для роботи з рядками — приклад класу, де неявний конструктор копіювання призводить до проблем.

String.cpp
#include <iostream>
#include <cstring>

class String
{
private:
    char* data;
    int length;

public:
    // Конструктор з C-рядка
    String(const char* str = "")
    {
        length = std::strlen(str);
        data = new char[length + 1];
        std::strcpy(data, str);
        std::cout << "Створено рядок: \"" << data << "\"\n";
    }

    // Деструктор
    ~String()
    {
        std::cout << "Видалення рядку: \"" << data << "\"\n";
        delete[] data;
    }

    // ⚠️ Поки що немає конструктора копіювання — використовується неявний

    void print() const
    {
        std::cout << "\"" << data << "\" (довжина: " << length << ")\n";
    }

    void set(const char* str)
    {
        delete[] data;
        length = std::strlen(str);
        data = new char[length + 1];
        std::strcpy(data, str);
    }
};

int main()
{
    String s1("Hello");
    s1.print();

    {
        String s2 = s1;  // ⚠️ Shallow copy!
        s2.print();
        std::cout << "s1.data: " << static_cast<void*>(s1.data) << "\n";
        std::cout << "s2.data: " << static_cast<void*>(s2.data) << "\n";
    }  // s2 знищується → delete[] data

    std::cout << "Після знищення s2:\n";
    // s1.print();  // ☠️ Crash — data вже видалена!

    return 0;
}
./program (можлива помилка)
$ ./program
Створено рядок: "Hello"
"Hello" (довжина: 5)
"Hello" (довжина: 5)
s1.data: 0x7f8e3c405870
s2.data: 0x7f8e3c405870
Видалення рядку: "Hello"
Після знищення s2:
Видалення рядку: (вже видалена пам'ять)
*** Error in program: double free detected

Проблема: обидва s1.data і s2.data вказують на одну адресу — shallow copy. Коли s2 знищується, пам'ять звільняється. s1.data стає висячим покажчиком. При знищенні s1 — подвійне видалення.

Рішення: заборона копіювання

Якщо правильне копіювання складне, краще заборонити його явно:

class String
{
public:
    // Заборона копіювання
    String(const String& other) = delete;

    // Решта коду без змін...
};

Тепер спроба String s2 = s1; призведе до помилки компіляції, а не до runtime crash.

У статті 66 ми реалізуємо глибоке копіювання (deep copy) для класу String — правильний конструктор копіювання, що виділяє окрему пам'ять і копіює дані, а не адресу.

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

✅ Потрібен власний

  • Клас має покажчики на динамічну пам'ять
  • Клас керує ресурсами (файли, з'єднання, м'ютекси)
  • Клас має складну логіку копіювання (лічильники, кеші)
  • Потрібне глибоке копіювання даних

✅ Використовуйте неявний

  • Клас має лише POD-поля (int, double, char)
  • Всі поля — об'єкти класів із правильним копіюванням (std::string, std::vector)
  • Неявний конструктор робить саме те, що потрібно

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

  • Клас представляє унікальний ресурс
  • Копіювання логічно не має сенсу (файл, з'єднання)
  • Копіювання дороге і завжди помилка

Правило п'яти пальців (Rule of Five)

У C++ існує практичне правило для класів із ресурсами:

Правило трьох (Rule of Three):
Якщо клас потребує одного з трьох спеціальних методів:
  1. Деструктор
  2. Конструктор копіювання
  3. Оператор присвоювання
...то йому майже напевно потрібні всі три.

Якщо клас керує ресурсом (динамічна пам'ять, файл), йому потрібен:

  • Деструктор — звільнити ресурс
  • Конструктор копіювання — правильно скопіювати ресурс
  • Оператор присвоювання — правильно перенести ресурс при присвоюванні

У C++11 це правило розширилося до Правила п'яти (Rule of Five), додавши: 4. Move-конструктор (ClassName(ClassName&& other)) 5. Move-оператор присвоювання (ClassName& operator=(ClassName&& other))

Move-семантика — тема наступних курсів, але принцип той самий: якщо клас керує ресурсами, потрібен повний контроль над усіма способами створення/копіювання/переміщення об'єктів.


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

Як і звичайні методи, конструктор копіювання можна визначити поза тілом класу:

Fraction.h
#ifndef FRACTION_H
#define FRACTION_H

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    Fraction(int num = 0, int den = 1);
    Fraction(const Fraction& other);  // Оголошення конструктора копіювання

    void print() const;
};

#endif
Fraction.cpp
#include "Fraction.h"
#include <iostream>

Fraction::Fraction(int num, int den) : numerator(num), denominator(den)
{
    if (denominator == 0) {
        denominator = 1;
    }
}

Fraction::Fraction(const Fraction& other)
    : numerator(other.numerator), denominator(other.denominator)
{
    std::cout << "Копіювання: " << numerator << "/" << denominator << "\n";
}

void Fraction::print() const
{
    std::cout << numerator << "/" << denominator;
}

Синтаксис стандартний: ClassName::ClassName(const ClassName& other).


Практичне завдання: клас Point із логуванням


Типові помилки та їх діагностика

Помилка 1: передача параметра за значенням у конструкторі копіювання

class Wrong
{
public:
    Wrong(Wrong other)  // ❌ Помилка!
    {
        // ...
    }
};

Проблема: щоб створити копію other, потрібно викликати конструктор копіювання. Щоб викликати конструктор копіювання, потрібно створити копію other. Безкінечна рекурсія.

Повідомлення компілятора:

error: invalid constructor; you probably meant 'Wrong(const Wrong&)'

Рішення: завжди приймайте параметр по посиланню.


Помилка 2: забули const

class Example
{
public:
    Example(Example& other)  // ⚠️ Не const
    {
        // ...
    }
};

Проблема: не можна скопіювати тимчасовий (rvalue) або const об'єкт:

const Example e1(/* ... */);
Example e2 = e1;  // ❌ Помилка — e1 є const
Example e3 = createExample();  // ❌ Помилка — результат тимчасовий

Рішення: завжди використовуйте const ClassName&.


Помилка 3: оголошено = delete, але передача за значенням

class NoCopy
{
public:
    NoCopy(const NoCopy& other) = delete;
};

void process(NoCopy obj)  // Параметр за значенням
{
    // ...
}

int main()
{
    NoCopy nc;
    process(nc);  // ❌ Помилка компіляції!
}

Повідомлення:

error: use of deleted function 'NoCopy::NoCopy(const NoCopy&)'

Рішення: якщо копіювання заборонено, передавайте об'єкти по посиланню (const NoCopy& або NoCopy&).


Підсумкова таблиця: конструктор копіювання

АспектДеталі
СигнатураClassName(const ClassName& other)
Коли викликаєтьсяІніціалізація, передача за значенням, повернення за значенням
НеявнийКомпілятор генерує автоматично (memberwise copy)
Небезпека неявногоShallow copy для класів із покажчиками
Заборона копіюванняClassName(const ClassName&) = delete;
Правило трьохДеструктор + конструктор копіювання + operator=

Рекомендації щодо використання

1️⃣ Для простих класів

Якщо клас має лише POD-поля або об'єкти стандартної бібліотеки (std::string, std::vector), неявний конструктор копіювання працює правильно. Нічого робити не потрібно.

2️⃣ Для класів із ресурсами

Якщо клас керує динамічною пам'яттю, файлами, з'єднаннями — або реалізуйте глибоке копіювання (стаття 66), або заборонте копіювання через = delete.

3️⃣ Унікальні ресурси

Якщо об'єкт представляє унікальний ресурс (файл, з'єднання, м'ютекс), копіювання логічно не має сенсу — завжди забороняйте через = delete.

4️⃣ Передача об'єктів

Завжди передавайте об'єкти класів по константному посиланню (const ClassName&), якщо функція не змінює об'єкт. Це уникає зайвого копіювання.

Висновки

Конструктор копіювання — це спеціальний конструктор, що створює новий об'єкт як копію існуючого. C++ автоматично копіює об'єкти у трьох ситуаціях: ініціалізація, передача за значенням, повернення за значенням.

Ключові моменти:

  1. Сигнатура: ClassName(const ClassName& other) — параметр обов'язково по посиланню, бажано const
  2. Неявний конструктор: компілятор генерує автоматично (memberwise copy)
  3. Небезпека shallow copy: для класів із покажчиками неявний конструктор призводить до подвійного видалення пам'яті
  4. Заборона копіювання: ClassName(const ClassName&) = delete; — явна заборона на рівні компілятора
  5. Правило трьох: клас із деструктором майже завжди потребує конструктора копіювання та оператора присвоювання

📝 Неявний конструктор

Працює автоматично для простих класів. Копіює кожне поле побайтово.

⚠️ Shallow copy

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

🚫 = delete

Явна заборона копіювання для унікальних ресурсів.

🔗 Правило трьох

Деструктор, конструктор копіювання, оператор присвоювання — разом або ніяк.

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

Copyright © 2026