Денис Злобин

Правило пяти

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

  1. Деструктор
  2. Конструктор копирования
  3. Оператор присваивания копированием
  4. Конструктор перемещения
  5. Оператор присваивания перемещением

Ты не просто познакомишься с участниками правила пяти, но и поймешь глубинный смысл применения этого правила.

Исследовать правило пяти будем с помощью наивного контейнера — класса IntBuffer, который владеет динамическим массивом целых чисел:

class IntBuffer
{
  private:
    int* buffer_{nullptr};
    int size_{0};
};

Согласно идиоме RAII класс должен захватывать ресурс в конструкторе, а освобождать в деструкторе. Ресурс, которым владеет класс IntBuffer, — динамический массив целых чисел. Напишем конструктор и деструктор для класса IntBuffer:

class IntBuffer
{
  public:
    IntBuffer(int size)
    {
        size_ = size;
        buffer_ = new int[size];
    }

    ~IntBuffer()
    {
        delete[] buffer_;
    }

  private:
    int size_{0};
    int* buffer_{nullptr};
};

Пользовательский код с использованием объекта класса IntBuffer:

int main()
{
    IntBuffer intBuffer1(1);
    // что-то делаем с объектом
}

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

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

Рядовая ситуация: нам захотелось инициализировать новый объект intBuffer2 уже имеющимся объектом intBuffer1

int main()
{
    IntBuffer intBuffer1(1);
    IntBuffer intBuffer2(intBuffer1);
    // что-то делаем с объектами
}

Компилятор сгенерит за нас конструктор копирования, код без проблем соберется, программа запустится и… эпично грохнется с ошибкой double free. Почему? Причина — сырые указатели не хранят в себе информации об объекте, адрес которого в них записан. Поэтому компилятор сгенерит конструктор копирования следующего вида:

IntBuffer(const IntBuffer& other)
{
    buffer_ = other.buffer_;
    size_ = other.size_;
}

В итоге получается, что два указателя (первый — указатель объекта intBuffer1, второй — intBuffer2) хранят в себе один и тот же адрес. При завершении работы программы сначала будет уничтожен объект intBuffer2, он освободит память по адресу указателя, после чего будет уничтожен объект intBuffer1, который попытается освободить уже освобожденную память. Давай исправим эту проблему и напишем в явном виде конструктор копирования:

IntBuffer(const IntBuffer& other)
{
    size_ = other.size_;
    buffer_ = new int[size_];
    std::memcpy(buffer_, other.buffer_, size_ * sizeof(int));
}

Что мы сделали: мы выделили новую память для нового объекта (в контексте нашей задачи это intBuffer2), после чего побайтово скопировали содержимое буфера intBuffer1 в буфер intBuffer2. Т.е. теперь объекты владеют разными участками памяти, но эти разные участки памяти содержат идентичные данные.

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

Рассмотрим следующий пользовательский код

int main()
{
    IntBuffer intBuffer1(1);
    IntBuffer intBuffer2(intBuffer1);

    IntBuffer intBuffer3(3);
    intBuffer1 = intBuffer3;

    // что-то делаем с объектами
}

Мы создали новый объект intBuffer3, после чего присвоили объекту intBuffer1 значение объекта intBuffer3. В этом случае мы также получим ошибку double free, потому что компилятор опять неправильно сгенерирует оператор присваивания копированием — это будет простое почленное присваивание:

IntBuffer& operator=(const IntBuffer& other)
{
    buffer_ = other.buffer_;
    size_ = other.size_;

    return *this;
}

Как ты уже догадался, нам придется написать оператор присваивания копированием самостоятельно:

IntBuffer& operator=(const IntBuffer& other)
{
    if (this == &other)
    {
        return *this;
    }

    int* newBuffer = new int[other.size_];
    std::memcpy(newBuffer, other.buffer_, other.size_ * sizeof(int));

    delete[] buffer_;
    buffer_ = newBuffer;
    size_ = other.size_;

    return *this;
}

Обрати внимание на порядок действий в реализации: я сначала выделил новую память и скопировал в нее данные (newBuffer), затем освободил память, которой уже владеет объект, и только потом подменил указатель buffer_. Такая реализация дает строгую гарантию исключений (exception safe): если при выделении памяти с помощью оператора new будет выброшено исключение, объект останется в исходном состоянии.

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

Рассмотрим следующий пользовательский код

int main()
{
    IntBuffer intBuffer1(1);
    IntBuffer intBuffer2(std::move(intBuffer1));

    // что-то делаем с объектами
}

Мы переместили объект intBuffer1 в объект intBuffer2. Конкретно этот код соберется и отработает, потому как при перемещении компилятор ищет перемещающий конструктор, не находит его, начинает искать копирующий конструктор, находит его и вызывает. Т.е. вместо перемещения мы получили копирование. Можно попросить компилятор сгенерировать конструктор перемещения за нас. Для этого добавим следующий код:

IntBuffer(IntBuffer&& other) = default;

Соберем код, запустим и получим ошибку double free — компилятор сгенерил неправильный конструктор перемещения:

IntBuffer(IntBuffer&& other)
{
    size_ = other.size_;
    buffer_ = other.buffer_;
}

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

Напишем реализацию самостоятельно:

IntBuffer(IntBuffer&& other) noexcept
{
    size_ = other.size_;
    buffer_ = other.buffer_;
    other.size_ = 0;
    other.buffer_ = nullptr;
}

Теперь код отрабатывает корректно.

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

Рассмотрим следующий пользовательский код

int main()
{
    IntBuffer intBuffer1(1);
    IntBuffer intBuffer2(std::move(intBuffer1));

    IntBuffer intBuffer3(3);
    intBuffer1 = std::move(intBuffer3);

    // что-то делаем с объектами
}

Рассуждения абсолютно аналогичные предыдущему пункту, посвященному конструктору перемещения: по умолчанию компилятор будет использовать оператор присваивания копированием, если попросить компилятор сгенерировать оператор присваивания перемещением, код будет падать с ошибкой double free. Напишем реализацию самостоятельно:

IntBuffer& operator=(IntBuffer&& other) noexcept
{
    if (this == &other)
    {
        return *this;
    }

    delete[] buffer_;

    buffer_ = other.buffer_;
    size_ = other.size_;
    other.buffer_ = nullptr;
    other.size_ = 0;

    return *this;
}

Соберем всех участников правила пяти вместе:

#include <cstring>

class IntBuffer
{
  public:
    IntBuffer(int size)
    {
        size_ = size;
        buffer_ = new int[size];
    }

    IntBuffer(const IntBuffer& other)
    {
        size_ = other.size_;
        buffer_ = new int[size_];
        std::memcpy(buffer_, other.buffer_, size_ * sizeof(int));
    }

    IntBuffer(IntBuffer&& other) noexcept
    {
        size_ = other.size_;
        buffer_ = other.buffer_;
        other.size_ = 0;
        other.buffer_ = nullptr;
    }

    IntBuffer& operator=(const IntBuffer& other)
    {
        if (this == &other)
        {
            return *this;
        }

        int* newBuffer = new int[other.size_];
        std::memcpy(newBuffer, other.buffer_, other.size_ * sizeof(int));

        delete[] buffer_;
        buffer_ = newBuffer;
        size_ = other.size_;

        return *this;
    }

    IntBuffer& operator=(IntBuffer&& other) noexcept
    {
        if (this == &other)
        {
            return *this;
        }

        delete[] buffer_;

        buffer_ = other.buffer_;
        size_ = other.size_;
        other.buffer_ = nullptr;
        other.size_ = 0;

        return *this;
    }

    ~IntBuffer()
    {
        delete[] buffer_;
    }

  private:
    int size_{0};
    int* buffer_{nullptr};
};

Заключение

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