Правило трёх в C++: что должен знать каждый разработчик

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Что такое Правило Трёх в 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;
    }
};

Обратите внимание: в конструкторе перемещения мы просто «крадём» ресурсы у другого объекта, а его указатель обнуляем. Это гораздо быстрее, чем глубокое копирование. Оператор перемещающего присваивания работает аналогично.

Блоги

Рекомендаций по книгам