Перейти к содержимому

Оригинальное название: Deep vs. Shallow

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

Почему семантика имеет значение

Когда вы копируете объект в C++, что именно копируется? Для простых типов ответ прост — все участники копируются по битам. Но когда объект владеет ресурсом через указатель, конструктор копий по умолчанию (сгенерированный компилятором) выполняет небольшую копию: он копирует значение указателя, но не данные, на которые он указывает. Это означает, что два объекта теперь используют один и тот же ресурс, что приводит к ошибкам и повреждению данных.

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

Маленькая проблема копирования

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

shallow_copy_bug.cpp
#include <iostream>
#include <cstring>

class BrokenString {
    char* data_;
    std::size_t len_;

public:
    BrokenString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    ~BrokenString() {
        delete[] data_;  // frees the buffer
    }

    // No copy constructor defined!
    // Compiler generates shallow copy: copies the pointer

    void print() const {
        std::cout << data_ << '\n';
    }
};

int main() {
    BrokenString a{"Hello"};
    {
        BrokenString b = a;  // shallow copy: b.data_ == a.data_
        b.print();           // "Hello" — works for now
    }  // b destroyed: delete[] b.data_ (same pointer as a.data_!)

    // a.data_ is now a dangling pointer — UNDEFINED BEHAVIOR
    a.print();  // may crash, print garbage, or appear to work
    return 0;
}

Реализация Deep Copy

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

deep_copy.cpp
#include <iostream>
#include <cstring>
#include <algorithm>

class SafeString {
    char* data_;
    std::size_t len_;

public:
    explicit SafeString(const char* s)
        : len_{std::strlen(s)}
        , data_{new char[std::strlen(s) + 1]}
    {
        std::strcpy(data_, s);
    }

    // Deep copy constructor
    SafeString(const SafeString& other)
        : len_{other.len_}
        , data_{new char[other.len_ + 1]}
    {
        std::strcpy(data_, other.data_);
    }

    // Deep copy assignment with self-assignment check
    SafeString& operator=(const SafeString& other) {
        if (this == &other) return *this;  // self-assignment guard

        // Allocate new resource BEFORE releasing old one
        char* new_data = new char[other.len_ + 1];
        std::strcpy(new_data, other.data_);

        // Release old resource
        delete[] data_;

        // Adopt new resource
        data_ = new_data;
        len_ = other.len_;
        return *this;
    }

    ~SafeString() { delete[] data_; }

    void print() const {
        std::cout << data_ << " (len=" << len_ << ")\n";
    }
};

int main() {
    SafeString a{"Hello"};
    SafeString b = a;     // deep copy: b gets its own buffer
    SafeString c{"World"};
    c = a;                // deep copy assignment
    a.print();  // Hello (len=5)
    b.print();  // Hello (len=5)
    c.print();  // Hello (len=5)
    return 0;
}

Оригинальное название: Copy-and-Swap Idiom

copy-and-swap idiom обеспечивает элегантную, безопасную реализацию оператора присвоения копий. Он повторно использует конструктор копий и функцию swap, обеспечивая высокую безопасность исключений с минимальным дублированием кода.

copy_and_swap.cpp
#include <iostream>
#include <cstring>
#include <utility>

class Buffer {
    char* data_;
    std::size_t size_;

public:
    explicit Buffer(std::size_t size)
        : size_{size}, data_{new char[size]{}} {}

    Buffer(const Buffer& other)
        : size_{other.size_}, data_{new char[other.size_]}
    {
        std::memcpy(data_, other.data_, size_);
    }

    ~Buffer() { delete[] data_; }

    // Copy-and-swap: take parameter by VALUE (invokes copy ctor)
    Buffer& operator=(Buffer other) {   // copy happens here
        swap(*this, other);             // swap resources
        return *this;
    }   // 'other' destroyed here, freeing our old resource

    // Non-throwing swap — essential for strong exception safety
    friend void swap(Buffer& a, Buffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }

    char& operator[](std::size_t i) { return data_[i]; }
    std::size_t size() const { return size_; }
};

int main() {
    Buffer a{5};
    a[0] = 'H'; a[1] = 'i';

    Buffer b{10};
    b = a;  // copy-and-swap: exception-safe, self-assignment-safe

    std::cout << b[0] << b[1] << " (size " << b.size() << ")\n";
    return 0;
}

Отключение копирования

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

class UniqueConnection {
public:
    UniqueConnection(const UniqueConnection&) = delete;
    UniqueConnection& operator=(const UniqueConnection&) = delete;
    // Move operations can still be enabled
};

До C++11 это было сделано путем объявления операций копирования private без определения — запутанный взлом, который элегантно заменяет =delete.

Подводный камень

Наивное присвоение копии, которое удаляет старый ресурс перед выделением нового, нарушается двумя способами: (1) самоназначение (a = a) удаляет данные, а затем пытается скопировать из него, и (2) если new бросает после delete, объект остается в недействительном состоянии. Идиома копирования и свопа решает обе проблемы: копия делается до каких-либо модификаций, а своп — noexcept.

Лучшая практика

Лучший способ избежать ошибок копирования — никогда не управлять ресурсами напрямую. Используйте std::string вместо char*, std::vector вместо необработанных массивов и std::unique_ptr вместо необработанных указателей. Когда все ваши участники имеют правильную семантику копирования, операции копирования, созданные компилятором, автоматически корректируются. Это правило ноль на практике.

Ключевые выводы
  • Сгенерированная компилятором копия shallow — она копирует значения указателей, а не указательные данные
  • Ресурс-собственные классы нуждаются в глубокой копии конструктора и оператора назначения
  • Идиома копирования и замены дает вам безопасность исключения и безопасность самоназначения бесплатно
  • Использование =delete для явного запрета копирования типов уникальных ресурсов
  • Лучшей стратегией является Правило нуля: используйте обертки RAII, чтобы вам никогда не понадобилась индивидуальная логика копирования.

Тест — проверьте свои знания

(15 XP)

1. Что происходит, когда вы неглубоко копируете объект, который владеет кучной памятью через необработанный указатель?

2. Почему идиома копирования и свопа принимает параметр по значению?

3. Какой подход рекомендуется использовать, чтобы избежать необходимости копирования на заказ?