Contrôle de Copie et Sémantique de Déplacement en C++

En C++, la gestion des ressources est un aspect fondamental de la programmation orientée objet. Les mécanismes de contrôle de copie sont essentiels pour définir comment les objets se comportent lorsqu'ils sont copiés, affectés ou déplacés. Ces mécanismes, notamment le constructeur de copie, l'opérateur d'affectation par copie, et plus récemment les références de valeur droite et le constructeur de déplacement introduits avec C++11, garantissent l'intégrité et l'efficacité des données.

Le Constructeur de Copie

Un constructeur de copie est un constructeur spécial qui est appelé lorsqu'un nouvel objet est créé à partir d'un objet existant du même type. Sa signature est caractérisée par un premier paramètre qui est une référence constante à une instance de la classe elle-même (par exemple, MaClasse(const MaClasse& autre)), et tous les paramètres additionnels doivent posséder des valeurs par défaut.

Le constructeur de copie synthétisé (par défaut) effectue une copie membre par membre. Cela fonctionne parfaitement pour les membres de type valeur (comme int, float) ou les objets qui gèrent correctement leur propre copie (comme std::string, std::vector). Cependant, pour les classes qui gèrent des ressources dynamiques (telles que des pointeurs vers de la mémoire allouée dynamiquement), un constructeur de copie par défaut peut entraîner des problèmes comme le "double free" ou des fuites de mémoire, car il copierait simplement l'adresse du pointeur au lieu de la ressource sous-jacente.

Voici les situations courantes où le constructeur de copie est invoqué :

  • Lorsqu'un objet est passé par valeur à une fonction.
  • Lorsqu'un objet est retourné par valeur depuis une fonction.
  • Lors de l'initialisation d'un objet avec un autre objet de même type (par exemple, MonObjet obj2 = obj1; ou MonObjet obj2(obj1);).
  • Lorsqu'un objet est inséré dans un conteneur qui le copie (par exemple, std::vector::push_back).

Considérons une classe simple gérant un identifiant ISBN pour illustrer le concept :


#include <iostream>
#include <cstring> // Pour strlen et strcpy
#include <string>  // Pour std::string
#include <vector>  // Pour démonstration dans main

class Livre {
private:
   char* identifiantISBN;
   double prix;

public:
   // Constructeur principal
   Livre(const char* isbnVal, double prixVal = 0.0) : prix(prixVal) {
       if (isbnVal) {
           identifiantISBN = new char[std::strlen(isbnVal) + 1];
           std::strcpy(identifiantISBN, isbnVal);
       } else {
           identifiantISBN = nullptr;
       }
       std::cout << "Constructeur principal appelé pour ISBN: " << (identifiantISBN ? identifiantISBN : "N/A") << std::endl;
   }

   // Constructeur de copie
   Livre(const Livre& autre) : prix(autre.prix) {
       if (autre.identifiantISBN) {
           identifiantISBN = new char[std::strlen(autre.identifiantISBN) + 1];
           std::strcpy(identifiantISBN, autre.identifiantISBN);
       } else {
           identifiantISBN = nullptr;
       }
       std::cout << "Constructeur de copie appelé pour ISBN: " << (identifiantISBN ? identifiantISBN : "N/A") << std::endl;
   }

   // Destructeur
   ~Livre() {
       if (identifiantISBN) {
           std::cout << "Destructeur appelé pour ISBN: " << identifiantISBN << std::endl;
           delete[] identifiantISBN;
           identifiantISBN = nullptr; // Pour éviter les pointeurs sauvages
       } else {
           std::cout << "Destructeur appelé pour ISBN: N/A (nullptr)" << std::endl;
       }
   }

   // Méthode d'affichage
   void afficherDetails() const {
       std::cout << "ISBN: " << (identifiantISBN ? identifiantISBN : "N/A") << ", Prix: " << prix << std::endl;
   }

   // Accesseur pour l'ISBN (utile pour la démo)
   const char* getISBN() const {
       return identifiantISBN;
   }
};

// Fonction prenant un objet par valeur, déclenchant le constructeur de copie
void traiterLivre(Livre livre) {
   std::cout << "Dans traiterLivre: ";
   livre.afficherDetails();
}

int main() {
   std::cout << "--- Début main ---" << std::endl;

   Livre monRoman("978-207036040", 25.50); // Appel du constructeur principal
   std::cout << "Adresse de monRoman: " << &monRoman << std::endl;

   std::cout << "\n--- Appel de traiterLivre (passage par valeur) ---" << std::endl;
   traiterLivre(monRoman); // Déclenche le constructeur de copie pour le paramètre 'livre'
   
   std::cout << "\n--- Initialisation par copie (Livre autreLivre = monRoman) ---" << std::endl;
   Livre autreLivre = monRoman; // Déclenche le constructeur de copie
   autreLivre.afficherDetails();

   std::cout << "\n--- Fin main ---" << std::endl;
   return 0;
}

Il est impératif que le premier paramètre d'un constructeur de copie soit une référence (et de préférence constante, const). Si ce n'était pas le cas, le passage de l'argument à ce constructeur nécessiterait lui-même une copie, ce qui déclencherait un appel récursif infini au constructeur de copie, entraînant une erreur de compilation ou un débordement de pile.

L'Opérateur d'Affectation par Copie

Contrairement au constructeur de copie qui initialise un nouvel objet, l'opérateur d'affectation par copie est invoqué lorsqu'un objet existant est affecté à un autre objet existant. Il est surchargé via la fonction membre operator=.

Lors de l'implémentation d'un opérateur d'affectation par copie pour une classe gérant des ressources, il est crucial de gérer correctement trois aspects :

  1. L'auto-affectation (obj = obj;) : L'objet ne doit pas se détruire lui-même avant de tenter de copier ses propres données.
  2. La libération des ressources existantes de l'objet de destination avant d'allouer de nouvelles ressources pour la copie.
  3. L'allocation de nouvelles ressources pour la copie.

La convention est de retourner une référence à l'objet courant (*this) pour permettre l'affectation en chaîne (obj1 = obj2 = obj3;).


#include <iostream>
#include <cstring> // Pour strlen et strcpy

class Article {
private:
   char* reference;
   int quantite;

public:
   // Constructeur par défaut (nécessaire pour Article B; B = A;)
   Article() : reference(nullptr), quantite(0) {
       std::cout << "Constructeur par défaut appelé." << std::endl;
   }

   // Constructeur principal
   Article(const char* refVal, int qty = 0) : quantite(qty) {
       if (refVal) {
           reference = new char[std::strlen(refVal) + 1];
           std::strcpy(reference, refVal);
       } else {
           reference = nullptr;
       }
       std::cout << "Constructeur principal appelé pour référence: " << (reference ? reference : "N/A") << std::endl;
   }

   // Destructeur
   ~Article() {
       if (reference) {
           std::cout << "Destructeur appelé pour référence: " << reference << std::endl;
           delete[] reference;
           reference = nullptr;
       } else {
           std::cout << "Destructeur appelé pour référence: N/A (nullptr)" << std::endl;
       }
   }

   // Constructeur de copie (pour la règle de trois/cinq)
   Article(const Article& autre) : quantite(autre.quantite) {
       if (autre.reference) {
           reference = new char[std::strlen(autre.reference) + 1];
           std::strcpy(reference, autre.reference);
       } else {
           reference = nullptr;
       }
       std::cout << "Constructeur de copie appelé pour référence: " << (reference ? reference : "N/A") << std::endl;
   }

   // Opérateur d'affectation par copie
   Article& operator=(const Article& autre) {
       std::cout << "Opérateur d'affectation par copie appelé." << std::endl;
       // 1. Gérer l'auto-affectation
       if (this == &autre) {
           return *this;
       }

       // 2. Libérer les ressources existantes
       if (reference) {
           delete[] reference;
           reference = nullptr;
       }

       // 3. Allouer et copier les nouvelles ressources
       quantite = autre.quantite;
       if (autre.reference) {
           reference = new char[std::strlen(autre.reference) + 1];
           std::strcpy(reference, autre.reference);
       } else {
           reference = nullptr;
       }

       return *this; // Retourner une référence à l'objet courant
   }

   void afficherInfo() const {
       std::cout << "Réf: " << (reference ? reference : "N/A") << ", Qté: " << quantite << std::endl;
   }
};

int main() {
   std::cout << "--- Début démo affectation ---" << std::endl;

   Article articleA("REF-001", 100); // Constructeur principal
   articleA.afficherInfo();

   Article articleB; // Constructeur par défaut
   std::cout << "Avant affectation B = A:" << std::endl;
   articleB.afficherInfo();

   articleB = articleA; // Appel de l'opérateur d'affectation par copie
   std::cout << "Après affectation B = A:" << std::endl;
   articleB.afficherInfo();

   std::cout << "\n--- Test d'auto-affectation ---" << std::endl;
   articleA = articleA; // Auto-affectation
   articleA.afficherInfo();

   Article articleC("REF-003", 50); // Constructeur principal
   articleC = articleA; // Affectation
   articleC.afficherInfo();

   std::cout << "--- Fin démo affectation ---" << std::endl;
   return 0;
}

Contrôle Explicite : = default et = delete

Depuis C++11, il est possible de spécifier explicitement le comportement des fonctions membres spéciales (constructeurs, destructeur, opérateurs d'affectation) en utilisant = default et = delete.

  • = default : Force le compilateur à générer la version synthétisée (par défaut) de la fonction membre. Cela est utile si vous avez déjà défini d'autres constructeurs et que vous souhaitez toujours que le constructeur de copie ou par défaut soit généré.

    
    class Produit {
    public:
       std::string nom;
       double poids;
    
       Produit() = default; // Constructeur par défaut synthétisé
       Produit(const Produit&) = default; // Constructeur de copie synthétisé
       Produit& operator=(const Produit&) = default; // Opérateur d'affectation par copie synthétisé
       ~Produit() = default; // Destructeur synthétisé
    };
    
    
  • = delete : Empêche le compilateur de générer ou d'utiliser une fonction membre. C'est particulièrement utile pour les classes qui ne doivent pas être copiées (par exemple, des classes de mutex ou des objets qui encapsulent une ressource unique).

    
    class RessourceUnique {
    public:
       RessourceUnique() = default;
       // Interdire la copie
       RessourceUnique(const RessourceUnique&) = delete;
       RessourceUnique& operator=(const RessourceUnique&) = delete;
       // ... autres membres
    };
    
    

    Il n'est pas permis de supprimer un destructeur avec = delete car cela empêcherait la destruction de l'objet, même après qu'il ait été déplacé.

Références de Valeur Droite (Rvalue References) et Sémantique de Déplacement

Avant C++11, les opérations de copie pour les objets complexes gérant des ressources dynamiques pouvaient être coûteuses. Chaque copie entraînait une allocation de mémoire et une copie profonde des données, même lorsque l'objet source était une valeur temporaire qui allait être immédiatement détruite. La sémantique de déplacement, introduite avec les références de valeur droite, résout ce problème en permettant de "voler" les ressources d'un objet temporaire plutôt que de les copier.

Imaginez un déménagement : au lieu de racheter tous les meubles pour votre nouvelle maison (copie profonde), vous transportez simplement vos meubles existants de l'ancienne à la nouvelle adresse (déplacement). L'ancienne adresse est alors vide et peut être rasée sans problème.

Une référence de valeur droite (notée &&) est une référence qui ne peut être liée qu'à une expression qui est une valeur droite (rvalue), c'est-à-dire un objet temporaire ou qui n'a pas d'emplacement de stockage persistant. Les rvalues sont transitoires, elles n'existent généralement que le temps d'une expression. En revanche, les valeurs gauches (lvalues) ont une identité et une adresse mémoire persistantes.

Exemples de rvalues : littéraux (42, "hello"), résultats d'expressions arithmétiques (x + y), retours de fonctions par valeur.


int valeur = 10;
int&& refRvalue1 = 20;       // Correct: 20 est une rvalue littérale
// int&& refRvalue2 = valeur; // Erreur: valeur est une lvalue
int&& refRvalue3 = valeur * 2; // Correct: valeur * 2 est une rvalue (résultat temporaire)

Pour lier une référence de valeur droite à une valeur gauche nommée (qui est une lvalue), il faut explicitement la convertir en rvalue à l'aide de std::move. L'utilisation de std::move indique que le programme n'a plus l'intention d'utiliser l'objet source, signalant qu'il est sûr d'en "voler" les ressources.


int x = 50;
int&& refRvalue4 = std::move(x); // Correct: 'x' est traité comme une rvalue
// À partir de ce point, 'x' ne doit plus être utilisé de manière valide,
// sauf pour sa destruction ou une nouvelle affectation.

L'adresse mémoire d'un objet ne change pas lorsqu'une référence de valeur droite est utilisée, elle crée simplement un alias pour un objet exsitant, temporaire ou non.


#include <iostream>
#include <vector>
#include <string>

class TempClass {
public:
   TempClass() { std::cout << "TempClass CTOR" << std::endl; }
   ~TempClass() { std::cout << "TempClass DTOR" << std::endl; }
};

TempClass createTemp() {
   return TempClass();
}

int main() {
   std::cout << "--- Démo Références de Valeur Droite ---" << std::endl;

   int nombre = 42;
   std::cout << "Adresse de nombre: " << &nombre << std::endl;

   int&& rrefNombre = std::move(nombre); // 'nombre' est maintenant "déplacé"
   std::cout << "Adresse de rrefNombre: " << &rrefNombre << std::endl;
   std::cout << "Valeur via rrefNombre: " << rrefNombre << std::endl;
   // std::cout << "Valeur via nombre: " << nombre << std::endl; // DANGEREUX! 'nombre' est dans un état valide mais indéterminé

   std::cout << "\nAppel de createTemp():" << std::endl;
   TempClass&& tempObj = createTemp(); // Lie à l'objet temporaire retourné par la fonction
   // L'objet temporaire est lié à tempObj et sa durée de vie est étendue.
   std::cout << "Fin démo Références de Valeur Droite." << std::endl;
   return 0;
}

Le Constructeur de Déplacement

Le constructeur de déplacement est une fonction membre spéciale qui prend une référence de valeur droite à un objet du même type. Son objectif est de transférer efficacement la propriété des ressources d'un objet source temporaire ou "déplacé" vers un nouvel objet, sans effectuer de copie coûteuse. Après le déplacement, l'objet source est laissé dans un état valide mais non spécifié, et ses ressources sont généralement nullifiées pour éviter leur double libération lors de sa destruction.

Le mot-clé noexcept est souvent ajouté au constructeur de déplacement pour indiquer qu'il ne lèvera pas d'exceptions. Cela permet aux conteneurs de la STL d'effectuer des optimisations (par exemple, pour std::vector, redimensionner sans craindre d'exceptions en cas de déplacement).


#include <iostream>
#include <cstring> // Pour strlen et strcpy
#include <utility> // Pour std::move
#include <vector>  // Pour démonstration dans main

class Document {
private:
   char* contenu; // Représente une ressource potentiellement volumineuse
   int taille;

public:
   // Constructeur par défaut
   Document() : contenu(nullptr), taille(0) {
       std::cout << "Document CTOR par défaut." << std::endl;
   }

   // Constructeur principal
   Document(const char* texte, int sz) : taille(sz) {
       if (texte) {
           contenu = new char[std::strlen(texte) + 1];
           std::strcpy(contenu, texte);
       } else {
           contenu = nullptr;
       }
       std::cout << "Document CTOR principal pour: '" << (contenu ? contenu : "N/A") << "'" << std::endl;
   }

   // Destructeur
   ~Document() {
       if (contenu) {
           std::cout << "Document DTOR pour: '" << contenu << "'" << std::endl;
           delete[] contenu;
           contenu = nullptr;
       } else {
           std::cout << "Document DTOR pour nullptr (déplacé ou par défaut)." << std::endl;
       }
   }

   // Constructeur de copie
   Document(const Document& autre) : taille(autre.taille) {
       if (autre.contenu) {
           contenu = new char[std::strlen(autre.contenu) + 1];
           std::strcpy(contenu, autre.contenu);
       } else {
           contenu = nullptr;
       }
       std::cout << "Document CTOR de copie pour: '" << (contenu ? contenu : "N/A") << "'" << std::endl;
   }

   // Opérateur d'affectation par copie
   Document& operator=(const Document& autre) {
       std::cout << "Document Opérateur d'affectation par copie." << std::endl;
       if (this == &autre) {
           return *this;
       }
       if (contenu) {
           delete[] contenu;
       }
       taille = autre.taille;
       if (autre.contenu) {
           contenu = new char[std::strlen(autre.contenu) + 1];
           std::strcpy(contenu, autre.contenu);
       } else {
           contenu = nullptr;
       }
       return *this;
   }

   // Constructeur de déplacement
   // 'noexcept' est crucial pour les conteneurs STL
   Document(Document&& autre) noexcept
       : contenu(autre.contenu), taille(autre.taille) { // Vole les ressources
       autre.contenu = nullptr; // Met l'objet source dans un état valide mais vide
       autre.taille = 0;
       std::cout << "Document CTOR de déplacement pour: '" << (contenu ? contenu : "N/A") << "'" << std::endl;
   }
   
   // Opérateur d'affectation de déplacement (Rule of Five)
   Document& operator=(Document&& autre) noexcept {
       std::cout << "Document Opérateur d'affectation de déplacement." << std::endl;
       if (this == &autre) {
           return *this;
       }

       // Libérer les ressources existantes de l'objet cible
       if (contenu) {
           delete[] contenu;
       }

       // Vole les ressources de l'objet source
       contenu = autre.contenu;
       taille = autre.taille;

       // Met l'objet source dans un état valide et vide
       autre.contenu = nullptr;
       autre.taille = 0;

       return *this;
   }

   void afficher() const {
       std::cout << "Contenu: '" << (contenu ? contenu : "VIDE") << "', Taille: " << taille << std::endl;
   }
};

int main() {
   std::cout << "--- Démo Constructeur de Déplacement ---" << std::endl;

   Document docOriginal("Ceci est un texte long pour un document.", 50); // CTOR principal
   docOriginal.afficher();

   std::cout << "\n--- Déplacement de docOriginal vers docNouveau ---" << std::endl;
   Document docNouveau = std::move(docOriginal); // Appel du constructeur de déplacement
   docNouveau.afficher();
   docOriginal.afficher(); // docOriginal est maintenant vide

   std::cout << "\n--- Création d'un document temporaire pour affectation de déplacement ---" << std::endl;
   Document docAffecte; // CTOR par défaut
   Document docTmp("Autre texte", 20); // CTOR principal
   docAffecte = std::move(docTmp); // Appel de l'opérateur d'affectation de déplacement
   docAffecte.afficher();
   docTmp.afficher(); // docTmp est maintenant vide

   std::cout << "\n--- Utilisation avec std::vector::push_back ---" << std::endl;
   std::vector<document> collectionDocs;
   collectionDocs.reserve(2); // Pour éviter les réallocations qui appelleraient des CTOR de copie/déplacement
   
   std::cout << "Push_back d'un document temporaire (move implicit):" << std::endl;
   collectionDocs.push_back(Document("Doc temporaire 1", 10)); // Move CTOR appelé implicitement
   collectionDocs.back().afficher();

   std::cout << "Push_back d'un lvalue 'docOriginal' déplacé:" << std::endl;
   collectionDocs.push_back(std::move(docNouveau)); // Move CTOR appelé
   collectionDocs.back().afficher();
   docNouveau.afficher(); // docNouveau est maintenant vide

   std::cout << "\n--- Fin Démo Constructeur de Déplacement ---" << std::endl;
   return 0;
}
</document>

Étiquettes: C++ Rvalue Reference Sémantique de Déplacement Constructeur de Copie Constructeur de Déplacement

Publié le 2 juillet à 00h29