Avatar ·

Что такое "Правило трёх" в C++ и почему без него утечка памяти?

Всем привет! Я начал изучать C++ и столкнулся с проблемой. Пишу простой класс, который хранит строку через динамическую память. Вроде бы всё логично, но программа падает или ведёт себя странно. Вот мой код: ```cpp class String { public: String(const char* str = "") { size = strlen(str); data = new char[size + 1]; strcpy(data, str); } ~String() { delete[] data; } private: char* data; int size; }; int main() { String s1("Hello"); String s2 = s1; // Копирование String s3("World"); s3 = s1; // Присваивание return 0; } ``` После запуска программа вылетает с ошибкой double free. Я понимаю, что проблема в том, что при копировании оба объекта указывают на одну и ту же память, а потом оба пытаются её удалить. Но как это правильно исправить? Что такое "Правило трёх", которое все упоминают? И почему говорят, что в C++11 появилось "Правило пяти"? Буду благодарен за подробное объяснение с примерами. <<>>

Что такое Правило трёх (Rule of Three)?

Правило трёх — это фундаментальный принцип C++, который гласит: если ваш класс явно определяет любой из следующих трёх специальных методов-членов, то он, скорее всего, должен определить все три:

  • Деструктор (~Class()) — для освобождения ресурсов.
  • Конструктор копирования (Class(const Class&)) — для корректного создания копии.
  • Оператор присваивания копированием (Class& operator=(const Class&)) — для корректного копирования в уже существующий объект.

Почему это важно? Компилятор автоматически генерирует эти методы, если вы их не определили. Но автогенерация делает поверхностное копирование (shallow copy) — просто копирует значения всех полей. Для классов с динамической памятью (как ваш String) это приводит к тому, что два объекта указывают на один и тот же блок памяти. Когда один объект уничтожается, он освобождает память, а второй объект остаётся с висячим указателем (dangling pointer). Итог — double free или undefined behavior.

Исправление вашего класса: реализация Правила трёх

class String {
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; // Защита от самоприсваивания
        }
        // 1. Освобождаем старые ресурсы
        delete[] data;
        // 2. Копируем новые
        size = other.size;
        data = new char[size + 1];
        strcpy(data, other.data);
        return *this;
    }

private:
    char* data;
    int size;
};

Теперь при копировании создаётся новый независимый массив. Каждый объект владеет своей памятью и безопасно её освобождает.

Правило пяти (Rule of Five) в C++11 и новее

С появлением семантики перемещения (move semantics) в C++11 добавили ещё два метода:

  • Конструктор перемещения (Class(Class&&))
  • Оператор присваивания перемещением (Class& operator=(Class&&))

Если вы определяете любой из пяти (деструктор, копирующий конструктор, копирующее присваивание, перемещающий конструктор, перемещающее присваивание), то рекомендуется определить все пять. Однако на практике, если вы следуете Правилу нуля (используете RAII-обёртки вроде std::string, std::vector, умные указатели), то компилятор сам сгенерирует корректные копирующие и перемещающие операции. Ваш класс String можно было бы написать так:

#include <string>

class String {
private:
    std::string data; // RAII-обёртка
public:
    String(const char* str = "") : data(str) {}
    // Никаких явных деструкторов, копирований и т.д. — компилятор сделает всё правильно!
};

Это и есть Правило нуля — идеальный подход в современном C++.

<<>>

Альтернативный подход: идиома "copy-and-swap"

В вашем примере оператор присваивания можно реализовать более элегантно и безопасно с помощью идиомы copy-and-swap. Она автоматически обеспечивает строгую гарантию безопасности исключений и избавляет от проверки на самоприсваивание.

class String {
public:
    // ... конструктор, деструктор, конструктор копирования (как выше) ...

    // Оператор присваивания через copy-and-swap
    String& operator=(String other) { // Принимаем по значению! Создаётся копия.
        swap(*this, other);           // Меняем местами с копией
        return *this;
    }

    // Дружественная функция для обмена
    friend void swap(String& a, String& b) noexcept {
        using std::swap;
        swap(a.data, b.data);
        swap(a.size, b.size);
    }
};

Как это работает:

  1. Параметр other принимается по значению — вызывается конструктор копирования (или перемещения, если передаётся временный объект).
  2. Мы обмениваем содержимое *this и other.
  3. При выходе из функции other уничтожается, унося с собой старые данные *this.

Этот подход часто проще и безопаснее ручного управления памятью в операторе присваивания. Он гарантирует, что если в процессе копирования возникнет исключение, исходный объект останется неизменным. Однако помните: для идиомы всё равно нужно корректно реализовать конструктор копирования и деструктор — то есть само Правило трёх.