Привет, Хабр!

Сегодня я хочу поговорить о двух правилах С++: правиле трех и правиле пяти.

Правильное понимание этих правил способно уберечь код от утечек и неопределенных поведений.

Правило трех

Скотт Мейерс, один из гуру C++ и автор шикарных книг, впервые сформулировал правило трех в своих книгах "Effective C++".

Правило трех которая гласит, что если классу нужен один из следующих трех методов, то, скорее всего, ему понадобятся и два других:

1. Деструктор — это специальный метод класса, который автоматически вызывается при уничтожении объекта. Деструктор — это тот персонаж боевичков, который убирает все следы операции перед уходом. Он гарантирует, что все выделенные ресурсы будут корректно освобождены, когда объект больше не нужен.

2. Конструктор копирования позволяет создавать новые объекты как копии существующих Т.е когда вы копируете объект, каждый бит информации из оригинала переносится в новый объект. Без явно определенного конструктора копирования, C++ предоставит стандартный, который скопирует все поля вашего объекта. Это не очень, если объект управляет внешним ресурсом, например, выделяет память. Почему? Потому что теперь два объекта будут думать, что они владеют одним и тем же ресурсом, и попытаются его освободить при уничтожении.

3. Оператор присваивания копированием позволяет одному уже существующему объекту принять состояние другого существующего объекта. Если этот оператор не будет определен явно, C++ сгенерирует его за вас, но, как и в случае с конструктором копирования, это может привести к проблемам с управлением ресурсами.

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

Рассмотрим пример класса, который не следует правилу трех:

class BrokenResource {
public:
    char* data;

    BrokenResource(const char* input) {
        data = new char[strlen(input) + 1];
        strcpy(data, input);
    }

    ~BrokenResource() {
        delete[] data;
    }
    // конструктор копирования и оператор присваивания не определены!
};

При копировании объекта BrokenResource компилятором будут сгенерированы конструктор копирования и оператор присваивания по умолчанию, которые просто копируют указатель data, ведущий к потенциальному двойному освобождению памяти.

Исправим предыдущий пример, явно реализовав правило трех:

class FixedResource {
public:
    char* data;

    FixedResource(const char* input) {
        data = new char[strlen(input) + 1];
        strcpy(data, input);
    }

    ~FixedResource() {
        delete[] data;
    }

    // конструктор копирования
    FixedResource(const FixedResource& other) {
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data);
    }

    // оператор присваивания
    FixedResource& operator=(const FixedResource& other) {
        if (this != &other) { // предотвращение самоприсваивания
            delete[] data; // освобождаем существующий ресурс
            data = new char[strlen(other.data) + 1];
            strcpy(data, other.data);
        }
        return *this;
    }
};

Добавили конструктор копирования и оператор присваивания, которые гарантируют, что каждый объект имеет свою собственную копию данных.

Правило пяти

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

  1. Деструктор.

  2. Конструктор копирования.

  3. Оператор присваивания копированием.

  4. Конструктор перемещения.

  5. Оператор присваивания перемещением.

Правило пяти также и правило пяти, решает проблемы эффективности и безопасности при работе с ресурсами. Копирование ресурсов может быть ресурсоемким с точки зрения производительности, а перемещение позволяет избежать ненужных операций копирования, передавая владение ресурсами непосредственно новому объекту.

Рассмотрим анти-пример:

class ResourceHolder {
public:
    int* data;

    ResourceHolder(int value) : data(new int(value)) {}
    ~ResourceHolder() { delete data; }
    // Правило пяти не соблюдено: отсутствуют конструктор копирования,
    // оператор присваивания копированием, конструктор перемещения и
    // оператор присваивания перемещением.
};

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

Соблюдение:

class ProperResource {
public:
    int* data;

    ProperResource(int value) : data(new int(value)) {}
    ~ProperResource() { delete data; }

    // конструктор копирования
    ProperResource(const ProperResource& other) : data(new int(*other.data)) {}

    // оператор присваивания копированием
    ProperResource& operator=(const ProperResource& other) {
        if (this != &other) {
            *data = *other.data;
        }
        return *this;
    }

    // конструктор перемещения
    ProperResource(ProperResource&& other) noexcept : data(other.data) {
        other.data = nullptr;
    }

    // оператор присваивания перемещением
    ProperResource& operator=(ProperResource&& other) noexcept {
        if (this != &other) {
            delete data;
            data = other.data;
            other.data = nullptr;
        }
        return *this;
    }
};

В ProperResource явно определены все пять специальных методов, управляющих копированием и перемещением ресурсов.

Умные указатели

Нельзя было не сказать о них в контексте этой статьи.

Умные указатели автоматически управляют памятью, делая код более чище.

C++ предлагает несколько типов умных указателей, но обратим внимание на два основных: std::unique_ptr и std::shared_ptr.

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

#include <memory>

void useUniquePtr() {
    std::unique_ptr<int> ptr(new int(10)); // инициализация с новым int
    // нет необходимости вызывать delete, уничтожение ptr автоматически освободит память
}

std::shared_ptr поддерживает совместное владение ресурсом через подсчет ссылок. Ресурс будет освобожден только тогда, когда последний std::shared_ptr, владеющий этим ресурсом, будет уничтожен или сброшен.

#include <memory>

void useSharedPtr() {
    std::shared_ptr<int> sharedPtr1 = std::make_shared<int>(20);
    std::shared_ptr<int> sharedPtr2 = sharedPtr1; // оба указателя сейчас владеют ресурсом.

    // ресурс будет автоматически освобожден после уничтожения последнего shared_ptr.
}

Одна из наиболее распространенных ошибок – это полное игнорирование необходимости реализации этих методов в классах, управляющих ресурсами. Это может привести к утечкам памяти, двойному освобождению ресурсов и другим неприятностям. Даже при реализации этих методов легко совершить ошибку, не корректно скопировав или переместив ресурсы, что приведет к аналогичным проблемам.

В современном C++ все используют умные указатели и прочие методы, которые самостоятельно управляют своими ресурсами, класс может вообще не требовать явной реализации этих пяти методов. Это известно как правило нуля.

Но об этих правилах тоже нельзя забывать, они внесли свой вклад в развитие ЯП C++.

В языке C++ есть множество вариантов решения задачи, которые часто будут отличаться различными свойствами по производительности и гибкости. К одной из таких возможностей можно отнести семантики копирования и перемещения. О том как они отличаются синтаксически и какие возможности по оптимизациям нам это открывает эксперты OTUS расскажут на бесплатном вебинаре. Регистрация на вебинар доступна по ссылке.

Комментарии (8)


  1. kovserg
    17.03.2024 12:57
    +6

    Если в правиле есть выражение "скорее всего" это не правило. Лучше когда правила строятся на основании аксиом.


    1. kchhay
      17.03.2024 12:57
      +5

      Эх, было бы здорово, если бы все было просто. В моем понимании, когда речь идёт о "правилах", не зафиксированных в компиляторе, то имеется в виду, что нарушать их можно, если для этого есть хорошее обоснование. То есть, если вы не понимаете, что делаете и у вас нет времени разбираться, то воспринимайте эти правила как аксиомы. Если понимаете и можете объяснить, то нарушайте, на здоровье.


  1. Cerberuser
    17.03.2024 12:57
    +10

    Мы стали более чище кодировать, а правило пяти работает так же, как правило пяти. OTUS принял в копирайтеры Черномырдина?


    1. dyadyaSerezha
      17.03.2024 12:57

      А ещё после раздела про указатели идёт ".. эти правила". Далеко не сразу понял, какие "эти".


  1. redfox0
    17.03.2024 12:57

    А потом эти же люди, что и пишут на с++, говорят, что синтаксис раста страшный и перегружен.


  1. Explorus
    17.03.2024 12:57

    Здесь явно не хватает информации о том, при каких условиях компилятор неявно добавляет специальные функции-члены. Популярно об этом разжевал в свое время Ховард Хинант, чей знаменитый доклад можно посмотреть здесь:https://www.youtube.com/watch?v=vLinb2fgkHk&t=2742s.
    А вот умные указатели тут вообще лишние, IMHO.


  1. MiyuHogosha
    17.03.2024 12:57

    Не раскрыто правило нуля из-за чего появилось "скорее всего". Непонятно, причем тут умные указатели, они являются частным случаем парадигмы RAII, их реальное устройство сложнее (если не братьboost::scoped_ptr за пример)


  1. 9241304
    17.03.2024 12:57

    Когда очень хотелось написать умную статью, но реальность дала сбой на этапе описания примеров. Можно пояснить, зачем писать класс для хранения в нем сишных сущностей? По каким причинам тут недоступна stl?