Chuyển đến nội dung

Copy Semantics: Deep vs Shallow

Learn the difference between shallow and deep copies, how to implement correct copy operations for resource-owning classes, and the copy-and-swap idiom.

Why Copy Semantics Matter

When you copy an object in C++, what exactly gets copied? For simple types, the answer is straightforward — all members are copied bit by bit. But when an object owns a resource through a pointer, the default (compiler-generated) copy constructor performs a shallow copy: it copies the pointer value but not the data it points to. This means two objects now share the same resource, leading to double-free bugs and data corruption.

Understanding copy semantics is essential because copies happen implicitly in many places: passing by value, returning from functions, inserting into containers, and assignment.

Shallow Copy Problem

The compiler-generated copy constructor copies each member. For pointers, this copies the address — not the pointed-to data. This is a shallow copy and it's dangerous for resource-owning classes.

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;
}

Implementing Deep Copy

A deep copy allocates new memory and copies the actual data. This ensures each object owns its own independent copy of the resource.

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;
}

Thành ngữ sao chép và trao đổi

Thành ngữ sao chép và trao đổi cung cấp cách triển khai thanh lịch, an toàn ngoại lệ cho toán tử gán sao chép. Nó sử dụng lại hàm tạo sao chép và hàmswap, mang lại sự an toàn ngoại lệ mạnh mẽ với sự trùng lặp mã tối thiểu.

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;
}

Vô hiệu hóa bản sao

Một số loại không thể sao chép được — ví dụ: các loại đại diện cho các tài nguyên duy nhất như phần xử lý tệp, kết nối mạng hoặc luồng. Sử dụng=deleteđể truyền đạt rõ ràng và thực thi điều này:

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

Before C++11, this was done by declaring copy operations private without a definition — a confusing hack that =xóa thay thế một cách tao nhã.

Lỗi thường gặp

A naive copy assignment that deletes the old resource before allocating the new one is broken in two ways: (1) self-assignment (a = a) deletes the data then tries to copy from it, and (2) ifnewthrows afterdelete, the object is left in an invalid state. The copy-and-swap idiom solves both problems: the copy is done before any modifications, and swap isnoexcept.

Thực hành tốt

The best way to avoid copy bugs is to never manage raw resources directly. Usestd::stringinstead ofchar*,std::vectorinstead of raw arrays, andstd::unique_ptrinstead of owning raw pointers. When all your members have correct copy semantics, the compiler-generated copy operations are automatically correct. This is the Rule of Zero in practice.

Điểm chính
  • Bản sao do trình biên dịch tạo là nông — nó sao chép các giá trị con trỏ, không phải dữ liệu trỏ tới
  • Các lớp sở hữu tài nguyên cần một hàm tạo bản sao sâu và toán tử gán
  • Thành ngữ sao chép và trao đổi mang đến cho bạn sự an toàn ngoại lệ và an toàn tự gán miễn phí
  • Sử dụng=deleteđể cấm sao chép rõ ràng các loại tài nguyên duy nhất
  • Chiến lược tốt nhất là Quy tắc số 0: sử dụng trình bao bọc RAII để bạn không bao giờ cần logic sao chép tùy chỉnh

Câu đố — Kiểm tra kiến thức

(15 XP)

1. Điều gì xảy ra khi bạn sao chép nông một đối tượng sở hữu bộ nhớ heap thông qua một con trỏ thô?

2. Tại sao thành ngữ sao chép và trao đổi lấy tham số theo giá trị?

3. Cách tiếp cận nào được khuyến nghị để tránh hoàn toàn cần đến các thao tác sao chép tùy chỉnh?