Μετάβαση στο περιεχόμενο

Copy Semantics: Deep vs Shallow

Μάθετε τη διαφορά μεταξύ ρηχών και βαθιών αντιγράφων, πώς να εφαρμόζετε σωστές λειτουργίες αντιγραφής για κλάσεις που διαθέτουν πόρους και το ιδίωμα αντιγραφής και ανταλλαγής.

Why Copy Semantics Matter

Όταν αντιγράφετε ένα αντικείμενο στο 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

Το ιδίωμα copy-and-swap παρέχει μια κομψή, ασφαλή εφαρμογή του τελεστή αντιγραφής. Χρησιμοποιεί ξανά τον κατασκευαστή αντιγραφής και μια λειτουργία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για ξεκάθαρη επικοινωνία και επιβολή αυτού:

Το ``cpp
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 =delete αντικαθιστά κομψά.

Παγίδα

Μια αφελής ανάθεση αντιγραφής που διαγράφει τον παλιό πόρο πριν από την εκχώρηση του νέου διακόπτεται με δύο τρόπους: (1) Η αυτοεκχώρηση (a = a) διαγράφει τα δεδομένα και στη συνέχεια προσπαθεί να αντιγράψει από αυτά και (2) εάν τοnewπετάξει μετά τοdelete, το αντικείμενο παραμένει σε μη έγκυρη κατάσταση. Το ιδίωμα αντιγραφής και ανταλλαγής επιλύει και τα δύο προβλήματα: η αντιγραφή γίνεται πριν από οποιεσδήποτε τροποποιήσεις και η εναλλαγή είναιnoexcept.

Βέλτιστη πρακτική

Ο καλύτερος τρόπος για να αποφύγετε σφάλματα αντιγραφής είναι να μην διαχειρίζεστε ποτέ τους πρωτογενείς πόρους απευθείας. Χρησιμοποιήστεstd::stringαντί γιαchar*,std::vectorαντί για ακατέργαστους πίνακες καιstd::unique_ptrαντί να κατέχετε ακατέργαστους δείκτες. Όταν όλα τα μέλη σας έχουν σωστή σημασιολογία αντιγραφής, οι λειτουργίες αντιγραφής που δημιουργούνται από τον μεταγλωττιστή είναι αυτόματα σωστές. Αυτός είναι ο Κανόνας του Μηδέν στην πράξη.

Βασικά συμπεράσματα
  • Το αντίγραφο που δημιουργείται από τον μεταγλωττιστή είναι ρηχό — αντιγράφει τιμές δείκτη και όχι δεδομένα με κατεύθυνση
  • Οι κλάσεις που διαθέτουν πόρους χρειάζονται ένα deep copy κατασκευαστή και τελεστή εκχώρησης
  • Το ιδίωμα αντιγραφή και ανταλλαγή σας προσφέρει ασφάλεια εξαίρεσης και ασφάλεια αυτο-ανάθεσης δωρεάν
  • Χρησιμοποιήστε το=deleteγια να απαγορεύσετε ρητά την αντιγραφή τύπων μοναδικών πόρων
  • Η καλύτερη στρατηγική είναι ο κανόνας του μηδενός: χρησιμοποιήστε περιτυλίγματα RAII, ώστε να μην χρειάζεστε ποτέ προσαρμοσμένη λογική αντιγραφής

Κουίζ — Δοκιμάστε τις γνώσεις σας

(15 XP)

1. Τι συμβαίνει όταν αντιγράφετε ρηχά ένα αντικείμενο που διαθέτει μνήμη σωρού μέσω ενός πρωτογενούς δείκτη;

2. Γιατί το ιδίωμα copy-and-swap παίρνει την παράμετρο ανά τιμή;

3. Ποια προσέγγιση συνιστάται για να αποφευχθεί εντελώς η ανάγκη προσαρμοσμένων λειτουργιών αντιγραφής;