رفتن به محتوا

معناشناسی کپی: عمیق در مقابل سطحی

تفاوت بین کپی سطحی و عمیق، نحوه پیاده‌سازی عملیات کپی صحیح برای کلاس‌های مالک منابع و اصطلاح کپی-و-تعویض را بیاموزید.

چرا معناشناسی کپی اهمیت دارد

وقتی یک شیء را در ‎C++‎ کپی می‌کنید، دقیقاً چه چیزی کپی می‌شود؟ برای انواع ساده، پاسخ واضح است — تمام اعضا بیت به بیت کپی می‌شوند. اما وقتی یک شیء از طریق یک اشاره‌گر مالک یک منبع است، سازنده کپی پیش‌فرض (تولیدشده توسط کامپایلر) یک کپی سطحی انجام می‌دهد: مقدار اشاره‌گر را کپی می‌کند نه داده‌ای که به آن اشاره می‌کند. این یعنی دو شیء اکنون یک منبع مشترک دارند که منجر به باگ‌های آزادسازی مضاعف (double-free) و خرابی داده می‌شود.

درک معناشناسی کپی ضروری است زیرا کپی‌ها به‌صورت ضمنی در بسیاری از مکان‌ها رخ می‌دهند: ارسال با مقدار، بازگشت از توابع، درج در ظروف و تخصیص.

مشکل کپی سطحی

سازنده کپی تولیدشده توسط کامپایلر هر عضو را کپی می‌کند. برای اشاره‌گرها، این آدرس را کپی می‌کند — نه داده‌ای که به آن اشاره شده است. این یک کپی سطحی است و برای کلاس‌های مالک منابع خطرناک است.

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.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)

اصطلاح کپی-و-تعویض (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 به‌صورت زیبایی جایگزین آن شده است.

دام

یک تخصیص کپی ساده‌لوحانه که منبع قدیمی را قبل از تخصیص جدید حذف می‌کند، از دو جهت معیوب است: (۱) تخصیص به خود (a = a) داده را حذف می‌کند و سپس سعی می‌کند از آن کپی کند، و (۲) اگر new پس از delete استثنا پرتاب کند، شیء در وضعیت نامعتبر باقی می‌ماند. اصطلاح کپی-و-تعویض هر دو مشکل را حل می‌کند: کپی قبل از هرگونه تغییر انجام می‌شود و swap عملیات noexcept است.

بهترین شیوه

بهترین راه برای جلوگیری از باگ‌های کپی این است که هرگز منابع خام را مستقیماً مدیریت نکنید. به جای char* از std::string، به جای آرایه‌های خام از std::vector و به جای اشاره‌گرهای خام مالک از std::unique_ptr استفاده کنید. وقتی همه اعضای شما معناشناسی کپی صحیحی دارند، عملیات کپی تولیدشده توسط کامپایلر به‌صورت خودکار صحیح هستند. این قانون صفر در عمل است.

نکات کلیدی
  • کپی تولیدشده توسط کامپایلر سطحی است — مقادیر اشاره‌گر را کپی می‌کند، نه داده‌ای که به آن اشاره شده
  • کلاس‌های مالک منابع به سازنده و عملگر تخصیص کپی عمیق نیاز دارند
  • اصطلاح کپی-و-تعویض ایمنی استثنا و ایمنی تخصیص به خود را به‌صورت رایگان فراهم می‌کند
  • از =delete برای منع صریح کپی انواع منبع منحصر به فرد استفاده کنید
  • بهترین استراتژی قانون صفر است: از پوشش‌دهنده‌های RAII استفاده کنید تا هرگز نیازی به منطق کپی سفارشی نداشته باشید

آزمون — دانش خود را بسنجید

(15 امتیاز)

1. وقتی یک شیء که از طریق اشاره‌گر خام مالک حافظه هیپ است را به‌صورت سطحی کپی کنید، چه اتفاقی می‌افتد؟

2. چرا اصطلاح کپی-و-تعویض پارامتر را با مقدار دریافت می‌کند؟

3. کدام رویکرد برای جلوگیری از نیاز به عملیات کپی سفارشی پیشنهاد می‌شود؟