Aller au contenu

Sémantique de copie : profonde vs superficielle

Apprenez la différence entre les copies superficielles et profondes, comment implémenter des opérations de copie correctes pour les classes possédant des ressources, et l'idiome copy-and-swap.

Pourquoi la sémantique de copie est importante

Quand vous copiez un objet en C++, qu'est-ce qui est exactement copié ? Pour les types simples, la réponse est directe — tous les membres sont copiés bit à bit. Mais quand un objet possède une ressource via un pointeur, le constructeur de copie par défaut (généré par le compilateur) effectue une copie superficielle : il copie la valeur du pointeur mais pas les données vers lesquelles il pointe. Cela signifie que deux objets partagent désormais la même ressource, menant à des bogues de double-free et de corruption de données.

Comprendre la sémantique de copie est essentiel car les copies se produisent implicitement dans de nombreux endroits : passage par valeur, retour depuis des fonctions, insertion dans des conteneurs et assignation.

Problème de la copie superficielle

Le constructeur de copie généré par le compilateur copie chaque membre. Pour les pointeurs, cela copie l'adresse — pas les données pointées. C'est une copie superficielle et c'est dangereux pour les classes possédant des ressources.

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

Implémenter une copie profonde

Une copie profonde alloue de la nouvelle mémoire et copie les données réelles. Cela garantit que chaque objet possède sa propre copie indépendante de la ressource.

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

L'idiome copy-and-swap

L'idiome copy-and-swap fournit une implémentation élégante et exception-safe de l'opérateur d'assignation par copie. Il réutilise le constructeur de copie et une fonction swap, offrant une sécurité d'exception forte avec une duplication de code minimale.

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

Désactiver la copie

Certains types ne devraient pas être copiables — par exemple, les types qui représentent des ressources uniques comme les descripteurs de fichiers, les connexions réseau ou les threads. Utilisez =delete pour communiquer et appliquer clairement cela :

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

Avant C++11, cela se faisait en déclarant les opérations de copie private sans définition — un hack confus que =delete remplace élégamment.

Piège

Une assignation par copie naïve qui supprime l'ancienne ressource avant d'allouer la nouvelle est défectueuse de deux façons : (1) l'auto-assignation (a = a) supprime les données puis tente de les copier, et (2) si new lance une exception après delete, l'objet est laissé dans un état invalide. L'idiome copy-and-swap résout les deux problèmes : la copie est faite avant toute modification, et swap est noexcept.

Bonne pratique

La meilleure façon d'éviter les bogues de copie est de ne jamais gérer directement des ressources brutes. Utilisez std::string au lieu de char*, std::vector au lieu de tableaux bruts, et std::unique_ptr au lieu de pointeurs bruts possédants. Quand tous vos membres ont une sémantique de copie correcte, les opérations de copie générées par le compilateur sont automatiquement correctes. C'est la règle de zéro en pratique.

Points clés
  • La copie générée par le compilateur est superficielle — elle copie les valeurs de pointeur, pas les données pointées
  • Les classes possédant des ressources ont besoin d'un constructeur de copie profonde et d'un opérateur d'assignation
  • L'idiome copy-and-swap vous donne la sécurité d'exception et la sécurité d'auto-assignation gratuitement
  • Utilisez =delete pour interdire explicitement la copie des types à ressources uniques
  • La meilleure stratégie est la règle de zéro : utilisez des wrappers RAII pour ne jamais avoir besoin de logique de copie personnalisée

Quiz — Testez vos connaissances

(15 XP)

1. Que se passe-t-il quand vous faites une copie superficielle d'un objet qui possède de la mémoire heap via un pointeur brut ?

2. Pourquoi l'idiome copy-and-swap prend-il le paramètre par valeur ?

3. Quelle approche est recommandée pour éviter complètement le besoin d'opérations de copie personnalisées ?