Конструктор копіювання та запобігання копіюванню
Конструктор копіювання та запобігання копіюванню
Коли 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 за значенням. Компілятор створює копію цього об'єкта для передачі назовні функції.
Неявний конструктор копіювання
У всіх трьох сценаріях вище ми не писали жодного коду для копіювання об'єктів. Звідки 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 тощо) неявний конструктор копіювання працює ідеально. Він копіює всі значення, створюючи незалежну копію об'єкта.
Явний конструктор копіювання: синтаксис
Конструктор копіювання — це спеціальний конструктор із строго визначеною сигнатурою:
ClassName(const ClassName& other);
Розбір сигнатури:
ClassName— ім'я класу (як і у всіх конструкторів)const ClassName&— параметр типу «константне посилання на об'єкт того ж класу»other(абоsource,copy) — ім'я параметра (будь-яке на ваш вибір)
Ключові деталі:
- Посилання обов'язкове: параметр має бути посиланням (
&). Якби він був значенням (ClassName other), виклик конструктора копіювання сам викликав би конструктор копіювання для створення копії параметра — безкінечна рекурсія! constрекомендовано: вихідний об'єкт не має змінюватися при копіюванні.constгарантує це на рівні компілятора.
Приклад: клас Fraction
#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;
}
Що відбувається
Рядок 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
}
Процес:
- Виклик
processFraction(original) - Параметр
f— локальна змінна функції - Викликається конструктор копіювання:
Fraction f(original) - Функція працює з копією
- При виході з функції викликається деструктор копії
Якщо об'єкт великий (багато полів, динамічна пам'ять), копіювання може бути дорогим. Тому для великих об'єктів використовують передачу по посиланню (const Fraction&).
3. Повернення за значенням
Fraction createFraction()
{
Fraction temp(7, 8);
return temp; // Копіювання temp → результат
}
int main()
{
Fraction result = createFraction(); // Копіювання результату → result
}
Теоретично відбувається два копіювання:
- З локальної змінної
tempу тимчасовий об'єкт результату функції - З тимчасового об'єкта у змінну
result
На практиці сучасні компілятори оптимізують це до нуля копій через RVO (Return Value Optimization). Але семантично конструктор копіювання має бути доступним.
= delete), код не скомпілюється, навіть якщо копіювання теоретично можна уникнути.Коли неявний конструктор копіювання небезпечний
Для простих класів із POD-полями неявний конструктор працює ідеально. Але що, якщо клас має динамічну пам'ять?
Проблема: shallow copy (поверхневе копіювання)
#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;
}
Що пішло не так
Неявний конструктор копіювання:
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.
Заборона копіювання: = delete
Іноді логіка класу забороняє копіювання. Наприклад:
- Унікальні ресурси: файловий дескриптор, мережеве з'єднання, м'ютекс — їх не можна просто скопіювати
- Класи-обгортки: клас, що керує єдиним екземпляром ресурсу (singleton pattern)
- Класи з дорогим копіюванням: якщо копіювання завжди помилка, краще заборонити на рівні компілятора
У C++11 і новіше для заборони копіювання використовується = delete:
class ClassName
{
public:
ClassName(const ClassName& other) = delete; // Копіювання заборонено
};
Приклад: клас FileHandle
#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;
}
Спроба розкоментувати 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 краще:
- Явна заява: «метод видалений, не можна використовувати»
- Помилка компіляції з чітким повідомленням
- Працює з шаблонами та перевантаженням
= delete для заборони копіювання, а не приватний конструктор.Копіювання vs присвоювання: різниця
Важливо розрізняти два поняття, які легко сплутати:
Fraction f1(3, 4);
Fraction f2 = f1; // ← Конструктор копіювання
Fraction f1(3, 4);
Fraction f2(1, 2);
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 (спрощений)
Розглянемо спрощений клас для роботи з рядками — приклад класу, де неявний конструктор копіювання призводить до проблем.
#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;
}
Проблема: обидва s1.data і s2.data вказують на одну адресу — shallow copy. Коли s2 знищується, пам'ять звільняється. s1.data стає висячим покажчиком. При знищенні s1 — подвійне видалення.
Рішення: заборона копіювання
Якщо правильне копіювання складне, краще заборонити його явно:
class String
{
public:
// Заборона копіювання
String(const String& other) = delete;
// Решта коду без змін...
};
Тепер спроба String s2 = s1; призведе до помилки компіляції, а не до runtime crash.
String — правильний конструктор копіювання, що виділяє окрему пам'ять і копіює дані, а не адресу.Коли потрібен власний конструктор копіювання
✅ Потрібен власний
- Клас має покажчики на динамічну пам'ять
- Клас керує ресурсами (файли, з'єднання, м'ютекси)
- Клас має складну логіку копіювання (лічильники, кеші)
- Потрібне глибоке копіювання даних
✅ Використовуйте неявний
- Клас має лише POD-поля (
int,double,char) - Всі поля — об'єкти класів із правильним копіюванням (
std::string,std::vector) - Неявний конструктор робить саме те, що потрібно
❌ Заборона копіювання
- Клас представляє унікальний ресурс
- Копіювання логічно не має сенсу (файл, з'єднання)
- Копіювання дороге і завжди помилка
Правило п'яти пальців (Rule of Five)
У C++ існує практичне правило для класів із ресурсами:
Якщо клас потребує одного з трьох спеціальних методів:
- Деструктор
- Конструктор копіювання
- Оператор присвоювання
Якщо клас керує ресурсом (динамічна пам'ять, файл), йому потрібен:
- Деструктор — звільнити ресурс
- Конструктор копіювання — правильно скопіювати ресурс
- Оператор присвоювання — правильно перенести ресурс при присвоюванні
У C++11 це правило розширилося до Правила п'яти (Rule of Five), додавши:
4. Move-конструктор (ClassName(ClassName&& other))
5. Move-оператор присвоювання (ClassName& operator=(ClassName&& other))
Move-семантика — тема наступних курсів, але принцип той самий: якщо клас керує ресурсами, потрібен повний контроль над усіма способами створення/копіювання/переміщення об'єктів.
Визначення конструктора копіювання поза класом
Як і звичайні методи, конструктор копіювання можна визначити поза тілом класу:
#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
#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 із логуванням
Створіть клас Point, що представляє точку на площині.
Вимоги:
- Приватні поля:
x(double),y(double) - Конструктор з двома параметрами (за замовчуванням 0.0)
- Конструктор копіювання, що виводить повідомлення про копіювання
- Метод
print() const— виводить координати - Метод
distanceTo(const Point& other) const— обчислює відстань до іншої точки - Статичне поле
copyCount— лічильник копіювань - Статичний метод
getCopyCount()— повертає кількість копіювань
Тестування:
- Створіть кілька точок
- Передайте точку у функцію за значенням — відстежте копіювання
- Передайте точку за константним посиланням — копіювання не має бути
- Виведіть загальну кількість копіювань
Point.h:
#ifndef POINT_H
#define POINT_H
class Point
{
private:
double x;
double y;
static int copyCount;
public:
Point(double xVal = 0.0, double yVal = 0.0);
Point(const Point& other);
void print() const;
double distanceTo(const Point& other) const;
static int getCopyCount() { return copyCount; }
};
#endif
Point.cpp:
#include "Point.h"
#include <iostream>
#include <cmath>
int Point::copyCount = 0;
Point::Point(double xVal, double yVal) : x(xVal), y(yVal)
{
std::cout << "Створено точку (" << x << ", " << y << ")\n";
}
Point::Point(const Point& other) : x(other.x), y(other.y)
{
copyCount++;
std::cout << "Копіювання точки (" << x << ", " << y << ") [копія #" << copyCount << "]\n";
}
void Point::print() const
{
std::cout << "(" << x << ", " << y << ")";
}
double Point::distanceTo(const Point& other) const
{
double dx = x - other.x;
double dy = y - other.y;
return std::sqrt(dx * dx + dy * dy);
}
main.cpp:
#include <iostream>
#include <iomanip>
#include "Point.h"
// Функція, що приймає Point за значенням (копіювання!)
void printPointByValue(Point p)
{
std::cout << " Отримано точку: ";
p.print();
std::cout << "\n";
}
// Функція, що приймає Point за константним посиланням (без копіювання)
void printPointByRef(const Point& p)
{
std::cout << " Отримано точку: ";
p.print();
std::cout << "\n";
}
int main()
{
std::cout << "=== Створення точок ===\n";
Point p1(3.0, 4.0);
Point p2(6.0, 8.0);
std::cout << "\n=== Передача за значенням (копіювання) ===\n";
printPointByValue(p1);
std::cout << "\n=== Передача за посиланням (без копіювання) ===\n";
printPointByRef(p1);
std::cout << "\n=== Копіююча ініціалізація ===\n";
Point p3 = p2;
std::cout << "\n=== Обчислення відстані ===\n";
std::cout << std::fixed << std::setprecision(2);
std::cout << "Відстань від ";
p1.print();
std::cout << " до ";
p2.print();
std::cout << " = " << p1.distanceTo(p2) << "\n";
std::cout << "\n=== Підсумки ===\n";
std::cout << "Всього копіювань: " << Point::getCopyCount() << "\n";
return 0;
}
Компіляція та запуск:
Аналіз:
✅ Копіювання відбулося двічі:
- При виклику
printPointByValue(p1)— передача за значенням - При
Point p3 = p2;— копіююча ініціалізація
✅ Копіювання НЕ відбулося:
- При виклику
printPointByRef(p1)— передача за константним посиланням - При виклику
distanceTo(p2)— параметрconst Point&
✅ Статичний лічильник:
- Зберігає загальну кількість копіювань усіх об'єктів класу
- Доступ через
Point::getCopyCount()
Типові помилки та їх діагностика
Помилка 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️⃣ Для простих класів
std::string, std::vector), неявний конструктор копіювання працює правильно. Нічого робити не потрібно.2️⃣ Для класів із ресурсами
= delete.3️⃣ Унікальні ресурси
= delete.4️⃣ Передача об'єктів
const ClassName&), якщо функція не змінює об'єкт. Це уникає зайвого копіювання.Висновки
Конструктор копіювання — це спеціальний конструктор, що створює новий об'єкт як копію існуючого. C++ автоматично копіює об'єкти у трьох ситуаціях: ініціалізація, передача за значенням, повернення за значенням.
Ключові моменти:
- Сигнатура:
ClassName(const ClassName& other)— параметр обов'язково по посиланню, бажаноconst - Неявний конструктор: компілятор генерує автоматично (memberwise copy)
- Небезпека shallow copy: для класів із покажчиками неявний конструктор призводить до подвійного видалення пам'яті
- Заборона копіювання:
ClassName(const ClassName&) = delete;— явна заборона на рівні компілятора - Правило трьох: клас із деструктором майже завжди потребує конструктора копіювання та оператора присвоювання
📝 Неявний конструктор
⚠️ Shallow copy
🚫 = delete
🔗 Правило трьох
На наступному уроці ми розглянемо дружні функції та класи (friend) — механізм контрольованого доступу до приватних членів класу ззовні, що дозволяє реалізувати тісну взаємодію між класами без порушення інкапсуляції.
Статичні члени класу
Статичні поля та методи класу, різниця між даними об'єкта та даними класу, ініціалізація статичних полів поза класом, inline static (C++17), практичні патерни: лічильник об'єктів, генератор ID, застереження щодо чисто статичних класів.
Дружні функції та класи
Чому іноді зовнішня функція потребує доступу до private-членів класу. Як оголошувати friend-функції та friend-класи, коли це доцільно, і як уникнути порушення інкапсуляції.