Règle de trois en C++

Jay Shaw 12 octobre 2023
  1. Comprendre la règle des trois grands en C++
  2. Définition implicite des constructeurs en C++
  3. Définition explicite des constructeurs en C++
  4. Conclusion
Règle de trois en C++

La règle des trois grands est l’un des idiomes de codage les plus populaires au monde. La loi stipule que C++ a des fonctions spéciales qui doivent être déclarées ensemble, même si une est requise.

Ces fonctions sont le constructeur de copie, l’opérateur d’affectation de copie et le destructeur.

La loi stipule que les deux autres doivent également suivre si l’une des trois fonctions spéciales est déclarée dans un programme. Sinon, le programme se heurte à de graves fuites de mémoire.

Cet article vous expliquera en détail la loi des trois grands et comment vous en sortir.

Comprendre la règle des trois grands en C++

Il faut considérer ce qui se passe lorsqu’une ressource allouée dynamiquement est ajoutée à une classe pour en savoir plus sur les trois grands.

Dans l’exemple ci-dessous, une classe constructeur est créée qui alloue de l’espace mémoire au pointeur.

class Big3 {
  int* x;

 public:
  Big3() : x(new int()) { std::cout << "Resource allocated \n"; }
};

Comme vu ci-dessus, la mémoire est allouée au pointeur. Maintenant, il doit également être libéré à l’aide d’un destructeur.

~Big3() {
  std::cout << "Resource is released \n";
  delete x;
}

La perception commune est que le travail est fait jusqu’à ce point. Mais la réalité n’est pas loin.

Certaines fuites de mémoire graves se produisent lorsqu’un constructeur de copie est lancé à l’intérieur de cette classe.

La raison derrière ce problème est la définition implicite des constructeurs. Lorsqu’un objet est copié sans définir de constructeur, le compilateur crée implicitement un constructeur de copie qui ne crée pas de clone mais juste une ombre du même objet.

Les constructeurs de copie, par défaut, exécutent une copie superficielle sur les objets. Le programme fonctionne correctement avec cette méthode de copie jusqu’à ce qu’une ressource soit dynamiquement allouée à un objet.

Par exemple, dans le code ci-dessous, un objet p1 est créé. Un autre objet, p2, est un objet de copie de p1.

int main() {
  Big3 p1;
  Big3 p2(p1);
}

Dans ce code, lorsque le destructeur détruit l’objet p1, p2 devient un pointeur pendant. En effet, l’objet p2 pointe sur la référence de l’objet p1.

Le code complet est donné ci-dessous pour une meilleure compréhension.

#include <iostream>

class Big3 {
  int* x;

 public:
  Big3() : x(new int()) { std::cout << "Resource allocated \n"; }
  ~Big3() {
    std::cout << "Resource is released \n";
    delete x;
  }
};

int main() {
  Big3 p1;
  Big3 p2(p1);
}

Pour éviter des problèmes comme un pointeur pendant, les programmeurs doivent déclarer explicitement tous les constructeurs requis, ce qui est l’objet de la règle des trois grands.

Définition implicite des constructeurs en C++

Il existe deux manières de créer une copie d’un objet.

  1. Copie superficielle - utilisation d’un constructeur pour copier l’adresse d’un objet et la stocker dans le nouvel objet.
  2. Copie en profondeur - en utilisant un constructeur similaire, qui copie les valeurs stockées à l’intérieur de cette adresse dans la nouvelle.

Habituellement, lorsque de la mémoire est allouée à un objet, la version implicite du constructeur de copie copie la référence du pointeur x au lieu de créer un nouvel objet avec son propre jeu d’allocation de mémoire.

Vous trouverez ci-dessous une représentation de la façon dont les fonctions membres spéciales sont implicitement définies.

book(const book& that) : name(that.name), slno(that.slno) {}

book& operator=(const book& that) {
  name = that.name;
  slno = that.slno;
  return *this;
}

~book() {}

Dans cet exemple, les trois fonctions membres spéciales sont définies. Le premier est le constructeur de copie, qui assigne les variables membres via une liste d’initialisation de membres : name(that.name), slno(that.slno).

Le deuxième constructeur est le constructeur d’affectation de copie qui crée une copie des objets de la classe. Ici, la surcharge d’opérateur est utilisée pour créer des copies de l’objet.

Au final, le destructeur reste vide car aucune ressource n’est allouée. Le code ne génère aucune erreur car les objets n’ont besoin d’aucune allocation de mémoire.

Pourquoi les définitions implicites échouent dans la gestion des ressources

Supposons qu’un pointeur membre de la classe reçoive une allocation de mémoire. La référence de ce pointeur de membre sera copiée dans le nouvel objet lorsque l’objet de cette classe est copié à l’aide de l’opérateur d’affectation par défaut et du constructeur de fonction de copie.

Par conséquent, toute modification apportée à un objet affectera également l’autre, car les nouveaux et anciens objets pointeront tous les deux vers le même emplacement mémoire. Le deuxième objet continuera à essayer de l’utiliser.

class person {
  char* name;
  int age;

 public:
  // constructor acquires a resource
  // dynamic memory obtained via new
  person(const char* the_name, int the_age) {
    name = new char[strlen(the_name) + 1];
    strcpy(name, the_name);
    age = the_age;
  }

  // destructor must release this resource via delete
  ~person() { delete[] name; }
};

L’exemple ci-dessus copie name, qui copie le pointeur, laissant les valeurs stockées à l’intérieur.

Lorsque le destructeur est déclaré, il supprime simplement l’instance de l’objet d’origine. Mais l’objet copié continue de pointer vers la même référence, qui est maintenant détruite.

C’est la cause principale des fuites de mémoire. Ils surviennent lorsque le destructeur supprime l’objet d’origine et sa référence, tandis que l’objet créé par le constructeur de copie reste suspendu et est également appelé pointeur suspendu.

De même, si un pointeur suspendu n’est pas coché, cette référence mémoire créera plusieurs fuites de mémoire à l’avenir.

La seule solution à ce problème est de déclarer explicitement les constructeurs, ou en termes simples, de créer son propre modèle de constructeur de copie et d’opérateur d’affectation pour résoudre ce problème.

Les constructeurs personnalisés copient les valeurs vers lesquelles le pointeur initial pointe plutôt que son adresse, allouant une mémoire séparée aux nouveaux objets.

Définition explicite des constructeurs en C++

Il a été observé que les constructeurs définis implicitement entraînaient une copie superficielle des objets. Pour résoudre ce problème, les programmeurs définissent explicitement un constructeur de copie, ce qui aide à copier en profondeur des objets en C++.

La copie en profondeur est la méthode qui attribue de nouveaux blocs de mémoire pour stocker la valeur d’un pointeur au lieu de simplement enregistrer la référence mémoire.

Cette méthode nécessite de définir explicitement les trois méthodes membres spéciales afin que le compilateur alloue de la nouvelle mémoire lors de la copie d’un objet.

Rôle des destructeurs dans les constructeurs définis explicitement en C++

Un destructeur doit être créé pour effacer la mémoire allouée à un objet fonction. Cela risque de provoquer une fuite de mémoire si vous ne le faites pas.

Dans les constructeurs implicites, même après avoir déclaré un destructeur, le problème persiste. Le problème se pose parce que si l’objet alloué à la mémoire est copié, l’objet copié pointera vers la même mémoire que l’objet d’origine.

Lorsque l’un supprime la mémoire dans son destructeur, l’autre aura un pointeur vers une mémoire invalide, et les choses se compliqueront lorsqu’il essaiera de l’utiliser.

En conséquence, un constructeur de copie explicitement défini doit être créé, donnant aux nouveaux objets leurs fragments de mémoire à purger.

Le programme ci-dessous montre une classe de démonstration qui adhère à la règle des trois grands.

class name {
 private:
  int* variable;  // pointer variable
 public:
  // Constructor
  name() { variable = new int; }

  void input(int var1)  // Parameterized method to take input
  {
    *variable = var1;
  }

  // Copy Constructor
  name(name& sample) {
    variable = new int;
    *variable = *(sample.variable);

    // destructor
    ~name() { delete variable; }
  };

Implémenter des constructeurs de copie en C++

Le programme a une classe Book avec un constructeur paramétré par défaut et un destructeur. Un constructeur par défaut renvoie des valeurs nulles lorsqu’aucune entrée n’est fournie, tandis qu’un constructeur paramétré initialise les valeurs et les copie.

Ici, une méthode de gestion des exceptions (try-catch) est incluse, qui lève des exceptions lorsque la variable m_Name ne peut pas allouer une ressource.

Après le destructeur, un constructeur de copie est créé qui fait une copie de l’objet d’origine.

#include <cstring>
#include <exception>
#include <iostream>

using namespace std;

class Book {
  int m_Slno;
  char* m_Name;

 public:
  // Default Constructor
  Book() : m_Slno(0), m_Name(nullptr) {}

  // Parametarized Constructor
  Book(int slNumber, char* name) {
    m_Slno = slNumber;
    unsigned int len = strlen(name) + 1;
    try {
      m_Name = new char[len];
    } catch (std::bad_alloc e) {
      cout << "Exception received: " << e.what() << endl;
      return;
    }
    memset(m_Name, 0, len);
    strcpy(m_Name, name);
  }

  // Destructor
  ~Book() {
    if (m_Name) {
      delete[] m_Name;
      m_Name = nullptr;
    }
  }

  friend ostream& operator<<(ostream& os, const Book& s);
};

ostream& operator<<(ostream& os, const Book& s) {
  os << s.m_Slno << ", " << s.m_Name << endl;
  return os;
}

int main() {
  Book s1(124546, "Digital Marketing 101");
  Book s2(134645, "Fault in our stars");

  s2 = s1;

  cout << s1;
  cout << s2;

  s1.~Book();
  cout << s2;

  return 0;
}

Dans la fonction main, lorsque l’objet s1 est détruit, s2 ne perd pas son objet dynamique, qui est la variable chaîne de l’objet s.

Un autre exemple ci-dessous montre comment copier en profondeur des objets à l’aide d’un constructeur de copie. Dans le code ci-dessous, une classe constructeur design est créée.

La classe a trois variables privées - deux des l et h statiques et un objet dynamique w.

#include <iostream>
using namespace std;

// A class design
class design {
 private:
  int l;
  int* w;
  int h;

 public:
  // Constructor
  design() { w = new int; }

  // Method to take input
  void set_dimension(int len, int brea, int heig) {
    l = len;
    *w = brea;
    h = heig;
  }

  // Display Function
  void show_data() {
    cout << "The Length is = " << l << "\n"
         << "Breadth  of the design = " << *w << "\n"
         << "Height = " << h << "\n"
         << endl;
  }

  // Deep copy is initialized here
  design(design& sample) {
    l = sample.l;
    w = new int;
    *w = *(sample.w);
    h = sample.h;
  }

  // Destructor
  ~design() { delete w; }
};

// Driver Code
int main() {
  // Object of class first
  design first;

  // Passing Parameters
  first.set_dimension(13, 19, 26);

  // Calling display method
  first.show_data();

  // Copying the data of 'first' object to 'second'
  design second = first;

  // Calling display method again to show the 'second' object
  second.show_data();

  return 0;
}

Production:

The Length is = 13
Breadth  of the design = 19
Height = 26

The Length is = 13
Breadth  of the design = 19
Height = 26
--------------------------------
Process exited after 0.2031 seconds with return value 0
Press any key to continue . . .

Conclusion

Cet article présente une explication complète et détaillée de la règle des 3 grands et comment elle affecte la programmation. Les lecteurs apprennent la nécessité et l’importance de la règle des 3 grands.

Parallèlement, certains nouveaux concepts sont expliqués, comme la copie profonde et superficielle, qui a plusieurs implémentations.