Что такое Правило Трёх в C++? Разбираем на примерах
Если вы когда-нибудь писали на C++ класс, который управляет динамической памятью (например, содержит new или malloc), то вы наверняка сталкивались с Правилом трёх (Rule of Three). Это одно из самых фундаментальных правил языка, которое спасает от утечек памяти, двойных удалений и неопределённого поведения. Давайте разберём его на реальных примерах, чтобы вы не просто запомнили правило, а поняли, почему оно работает.
Представьте: вы написали класс, который выделяет память в конструкторе. Компилятор любезно генерирует за вас конструктор копирования и оператор присваивания. Но делает он это поверхностно (shallow copy). И тут начинаются проблемы. Правило трёх говорит: если ваш класс явно определяет деструктор, конструктор копирования или оператор присваивания, то, скорее всего, вам нужно определить все три. Почему? Потому что это сигнал, что ваш класс управляет каким-то ресурсом (памятью, файлом, сетевым соединением), и копирование должно быть глубоким.
Проблема: поверхностное копирование (shallow copy)
Давайте посмотрим на типичный пример. Класс String, который хранит C-строку в динамической памяти:
class String {
private:
char* data;
size_t size;
public:
// Конструктор
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
strcpy(data, str);
}
// Деструктор
~String() {
delete[] data;
}
// Конструктор копирования — сгенерирован компилятором (поверхностный)
// Оператор присваивания — сгенерирован компилятором (поверхностный)
void print() const {
std::cout << data << std::endl;
}
};
Всё выглядит хорошо, пока мы не попробуем скопировать объект:
int main() {
String s1("Hello");
String s2 = s1; // Вызов конструктора копирования (сгенерированного)
s1.print();
s2.print();
// Программа упадёт с ошибкой double free!
return 0;
}
Что произошло? Компилятор сгенерировал конструктор копирования, который просто скопировал указатель data. Теперь s1.data и s2.data указывают на один и тот же блок памяти. Когда объекты выходят из области видимости, вызываются их деструкторы. Первый деструктор освобождает память, второй пытается освободить её снова — double free. Программа падает или ведёт себя непредсказуемо.
Решение: Правило трёх в действии
Чтобы исправить ситуацию, нужно реализовать все три специальные функции-члены. Это и есть Правило трёх. Давайте напишем правильную версию класса:
class String {
private:
char* data;
size_t size;
public:
// Конструктор
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
strcpy(data, str);
}
// Деструктор
~String() {
delete[] data;
}
// Конструктор копирования — глубокое копирование
String(const String& other) {
size = other.size;
data = new char[size + 1];
strcpy(data, other.data);
}
// Оператор присваивания — глубокое копирование
String& operator=(const String& other) {
if (this == &other) return *this; // защита от самоприсваивания
delete[] data; // освобождаем старые ресурсы
size = other.size;
data = new char[size + 1];
strcpy(data, other.data);
return *this;
}
void print() const {
std::cout << data << std::endl;
}
};
Теперь копирование работает корректно: каждый объект владеет своим блоком памяти. Присваивание тоже безопасно: мы сначала освобождаем старые ресурсы, потом выделяем новые. А проверка на самоприсваивание (if (this == &other)) защищает от случая, когда объект присваивают самому себе.
Почему Правило трёх — это важно?
Правило трёх — это не просто академическое знание. Это практический инструмент, который помогает писать надёжный код. Без него вы рискуете получить:
- Утечки памяти — если не освободить старые ресурсы в операторе присваивания.
- Двойное удаление — как в примере выше.
- Неопределённое поведение — при работе с уже освобождённой памятью.
На Stack Overflow вопрос «What is The Rule of Three?» набрал тысячи просмотров, потому что это одна из самых частых ошибок новичков (и не только).
Современный C++11: Правило пяти (Rule of Five)
С появлением C++11 правило обновилось. Добавились семантика перемещения (move semantics) и конструктор перемещения с оператором перемещающего присваивания. Теперь правильнее говорить о Правиле пяти. Но суть та же: если вы управляете ресурсами, определите все пять функций.
Вот как выглядит класс String с учётом правил пяти:
class String {
private:
char* data;
size_t size;
public:
// Конструктор
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
strcpy(data, str);
}
// Деструктор
~String() {
delete[] data;
}
// Конструктор копирования
String(const String& other) : size(other.size), data(new char[other.size + 1]) {
strcpy(data, other.data);
}
// Оператор присваивания копированием
String& operator=(const String& other) {
if (this == &other) return *this;
delete[] data;
size = other.size;
data = new char[size + 1];
strcpy(data, other.data);
return *this;
}
// Конструктор перемещения
String(String&& other) noexcept : data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// Оператор перемещающего присваивания
String& operator=(String&& other) noexcept {
if (this == &other) return *this;
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
return *this;
}
void print() const {
if (data) std::cout << data << std::endl;
}
};
Обратите внимание: в конструкторе перемещения мы просто «крадём» ресурсы у другого объекта, а его указатель обнуляем. Это гораздо быстрее, чем глубокое копирование. Оператор перемещающего присваивания работает аналогично.