Заметки по языку с и с++

Правило пяти (Rule of Five) в C++: конструкторы копирования/перемещения на практике

3 просмотров

Введение

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

Суть концепции

Компилятор C++ автоматически генерирует эти пять функций по умолчанию (с некоторыми условиями), но дефолтная генерация выполняет лишь поверхностное (shallow) копирование/перемещение членов класса. Если класс владеет ресурсом через сырой указатель, дефолтное копирование скопирует указатель, но не сам ресурс — два объекта окажутся «владельцами» одной и той же памяти, и при удалении одного из них второй останется с висящим указателем (dangling pointer), что приведёт к двойному освобождению при удалении второго.

#include <algorithm>
#include <cstring>
#include <iostream>

class Buffer
{
public:
    explicit Buffer(size_t size)
        : size_(size), data_(new char[size])
    {
        std::cout << "Конструктор: выделено " << size_ << " байт" << std::endl;
        std::fill(data_, data_ + size_, 0);
    }

    // 1. Деструктор
    ~Buffer()
    {
        std::cout << "Деструктор: освобождено " << size_ << " байт" << std::endl;
        delete[] data_;
    }

    // 2. Конструктор копирования: глубокое копирование
    Buffer(const Buffer& other)
        : size_(other.size_), data_(new char[other.size_])
    {
        std::cout << "Конструктор копирования" << std::endl;
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // 3. Оператор копирующего присваивания
    Buffer& operator=(const Buffer& other)
    {
        std::cout << "Оператор копирующего присваивания" << std::endl;
        if (this == &other) {
            return *this; // защита от самоприсваивания
        }
        char* newData = new char[other.size_]; // сначала выделяем
        std::copy(other.data_, other.data_ + other.size_, newData);
        delete[] data_;       // потом освобождаем старое (exception-safety)
        data_ = newData;
        size_ = other.size_;
        return *this;
    }

    // 4. Конструктор перемещения: забираем ресурс, не копируя
    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_)
    {
        std::cout << "Конструктор перемещения" << std::endl;
        other.data_ = nullptr; // у "донора" больше нет владения
        other.size_ = 0;
    }

    // 5. Оператор перемещающего присваивания
    Buffer& operator=(Buffer&& other) noexcept
    {
        std::cout << "Оператор перемещающего присваивания" << std::endl;
        if (this == &other) {
            return *this;
        }
        delete[] data_;
        data_ = other.data_;
        size_ = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
        return *this;
    }

    size_t size() const { return size_; }

private:
    size_t size_;
    char* data_;
};

Buffer createBuffer()
{
    Buffer b(64);
    return b; // компилятор использует перемещение, а не копирование
}

int main()
{
    Buffer a(128);
    Buffer b = a;             // конструктор копирования
    Buffer c = std::move(a);  // конструктор перемещения
    Buffer d = createBuffer(); // перемещение (или вообще пропуск копии — copy elision)

    b = c;             // оператор копирующего присваивания
    c = std::move(d);  // оператор перемещающего присваивания

    return 0;
}

Разбор примера

Класс Buffer владеет сырым указателем data_ на массив в куче. Без явных реализаций всех пяти функций дефолтный конструктор копирования просто скопировал бы значение указателя data_, и оба объекта (оригинал и копия) указывали бы на одну и ту же память — при удалении одного из них второй стал бы держать висящий указатель, а двойной delete[] привёл бы к неопределённому поведению.

Конструктор копирования и оператор копирующего присваивания выполняют глубокое копирование: выделяют собственную память и копируют в неё байты. Обратите внимание на порядок операций в operator=: новая память выделяется до освобождения старой — это защищает от потери данных, если new бросит std::bad_alloc (так называемая exception-safety, безопасность по отношению к исключениям).

Конструктор перемещения и оператор перемещающего присваивания, напротив, не копируют байты, а просто «забирают» указатель у временного объекта (other), оставляя исходный объект в валидном, но пустом состоянии (data_ = nullptr). Перемещение — это операция O(1) независимо от размера буфера, в отличие от копирования, которое всегда O(n).

В createBuffer() компилятор, скорее всего, вообще пропустит и копирование, и перемещение благодаря оптимизации copy elision (гарантированной в C++17 для подобных случаев), но даже если бы оптимизация не сработала, был бы вызван конструктор перемещения, а не копирования — это и есть причина, почему перемещающие операции должны быть эффективными и желательно noexcept.

Подводные камни и советы

  • Если вы не управляете ресурсом напрямую — не пишите эти пять функций вообще. Используйте std::vector, std::string, std::unique_ptr/std::shared_ptr как члены класса — они уже правильно реализуют Rule of Five, и дефолтные (compiler-generated) специальные функции вашего класса будут работать корректно автоматически. Это называется Rule of Zero — лучшая практика, к которой стоит стремиться.
  • Помечайте перемещающие операции noexcept, когда это возможно. Контейнеры стандартной библиотеки (например, std::vector при увеличении capacity) предпочитают перемещение копированию только если перемещающий конструктор гарантированно не бросает исключений; иначе они «на всякий случай» используют более медленное копирование.
  • Объявление любого из пяти специальных методов подавляет автогенерацию некоторых остальных (например, объявление деструктора подавляет неявную генерацию конструктора и оператора перемещения). Если нужен дефолтный вариант — объявите его явно как = default.
  • Защита от самоприсваивания обязательна в копирующем присваивании в стиле «вручную управляемых ресурсов» (хотя есть и идиома copy-and-swap, избегающая этой проверки за счёт временной копии).
  • Соблюдение Rule of Five — фундамент написания корректных низкоуровневых классов в C++; в большинстве прикладного кода современного C++ оно проявляется через Rule of Zero — просто не пишите эти функции, доверившись членам-обёрткам.

================================================================================