Avatar ·

Что такое «Правило трёх» в C++ и почему его нарушение приводит к утечкам памяти?

Привет! Я начал изучать C++ и столкнулся с непонятной ошибкой. У меня есть класс `SimpleString`, который хранит динамическую строку. Я написал конструктор и деструктор, но когда я пытаюсь скопировать объект или присвоить один объект другому, программа падает или выводит мусор. Вот пример кода: ```cpp #include #include class SimpleString { public: char* data; SimpleString(const char* str) { data = new char[strlen(str) + 1]; strcpy(data, str); std::cout << "Constructed: " << data << std::endl; } ~SimpleString() { delete[] data; std::cout << "Destructed" << std::endl; } }; int main() { SimpleString a("Hello"); SimpleString b = a; // Копирование SimpleString c("World"); c = a; // Присваивание return 0; } ``` Программа падает с ошибкой double free или выводит искажённые строки. Я понимаю, что проблема в том, что после копирования два объекта указывают на одну и ту же память, и деструктор удаляет её дважды. Но как это правильно исправить? Я слышал про «Правило трёх», но не до конца понимаю, что это такое. Объясните, пожалуйста, на понятных примерах. <<>>

Что такое Правило трёх?

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

Почему ваша программа падает?

В вашем классе SimpleString есть деструктор, который освобождает память. Но нет конструктора копирования и оператора присваивания. Когда вы пишете SimpleString b = a;, компилятор создаёт b путём копирования всех полей из a. Поле data — это указатель. В результате b.data и a.data указывают на один и тот же блок памяти.

  • Когда объект b уничтожается, его деструктор вызывает delete[] data — память освобождается.
  • Когда объект a уничтожается, его деструктор пытается освободить ту же самую память — происходит double free (повторное освобождение), что приводит к неопределённому поведению (обычно к падению программы).
  • Аналогичная проблема с присваиванием c = a: сначала указатель c.data теряется (утечка памяти), а затем оба указывают на одну область.

Правильное решение: реализуем все три метода

Вот исправленная версия класса SimpleString, соблюдающая Правило трёх:

#include <iostream>
#include <cstring>

class SimpleString {
public:
    char* data;

    // Конструктор
    SimpleString(const char* str) {
        data = new char[strlen(str) + 1];
        strcpy(data, str);
        std::cout << "Constructed: " << data << std::endl;
    }

    // Деструктор
    ~SimpleString() {
        delete[] data;
        std::cout << "Destructed" << std::endl;
    }

    // Конструктор копирования (глубокое копирование)
    SimpleString(const SimpleString& other) {
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data);
        std::cout << "Copy constructed: " << data << std::endl;
    }

    // Оператор присваивания копированием (глубокое копирование)
    SimpleString& operator=(const SimpleString& other) {
        if (this == &other) {
            return *this; // Защита от самоприсваивания
        }
        // Освобождаем старую память
        delete[] data;
        // Выделяем новую и копируем
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data);
        std::cout << "Assigned: " << data << std::endl;
        return *this;
    }
};

Что изменилось?

  • Конструктор копирования выделяет новую память и копирует содержимое строки, а не просто копирует указатель. Теперь каждый объект владеет своей собственной памятью.
  • Оператор присваивания сначала освобождает старую память (чтобы избежать утечки), затем выделяет новую и копирует данные. Также добавлена проверка на самоприсваивание (if (this == &other)), чтобы случайно не удалить самого себя.

Современный C++ и Правило пяти

С появлением C++11 добавились семантика перемещения (move semantics). Правило трёх расширилось до Правила пяти: если вы определяете деструктор, конструктор копирования или оператор присваивания, вам также стоит определить конструктор перемещения и оператор присваивания перемещением. Но для большинства практических задач в современном C++ рекомендуется вообще избегать ручного управления памятью и использовать стандартные контейнеры (например, std::string), которые уже реализуют все эти правила корректно.

Помните: Правило трёх — это не просто формальность, а способ предотвратить утечки памяти, двойные удаления и другие трудноуловимые ошибки.

<<>>

Альтернативный взгляд: используйте идиому «Управление ресурсами через RAII»

Вместо того чтобы вручную реализовывать все три метода, можно применить более безопасный подход — делегировать управление памятью умным указателям. Например, используйте std::unique_ptr или std::shared_ptr. Это автоматически решит проблему, так как умные указатели сами реализуют Правило трёх (и даже Правило пяти) внутри себя.

#include <iostream>
#include <memory>
#include <cstring>

class SimpleString {
public:
    std::unique_ptr<char[]> data;

    SimpleString(const char* str) 
        : data(std::make_unique<char[]>(strlen(str) + 1)) {
        strcpy(data.get(), str);
        std::cout << "Constructed: " << data.get() << std::endl;
    }

    // Конструктор копирования и оператор присваивания автоматически запрещены!
    // (unique_ptr не копируется, только перемещается)
};

Важное замечание: std::unique_ptr запрещает копирование, поэтому вы не сможете написать SimpleString b = a; — компилятор выдаст ошибку. Если копирование всё же нужно, используйте std::shared_ptr, но помните о накладных расходах. Для большинства задач лучше всего просто использовать std::string — он уже реализует всё необходимое, и вам не придётся писать ни одного из этих методов. Это и есть суть современного C++: доверять стандартной библиотеке.

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