Classe canonique : ni fuite mémoire, ni trap

Contenu du snippet

Cet article est destiné à attirer l'attention sur une erreur FREQUEMMENT rencontrée ... et sa parade
lorsque l'on crée une classe, il faut qu'elle soit utilisable sans danger par les développeurs
Après avoir montré que le compilateur fait tout ce qu'il peut pout rendre les classes canoniques, on explique pourquoi et comment compléter son travail.
De plus, on fournit un moyen (sous forme de jeu de test) de détecter si notre classe est bien canonique

PARTIE I : une classe trop simple, mais moins qu'on le croit
PARTIE II : une classe est naturellement canonique
PARTIE III : une classe DANGEREUSE (à rendre manuellement canonique)
PARTIE IV : une classe CORRECTE du point de vue de la canonicité
PARTIE V : une autre classe CORRECTE du point de vue de la canonicité : la méthode du paresseux ?
RESUME : quand s'inquiéter et que faire ?

Source / Exemple :


//le code est un peu long, mais peu de choses sont rajoutées à chaque étape
//il permet de tester chaque étape après copier/collé global et réglage du #define 

#define N 1 // a faire varier de 1 à 5 pour voir les différents cas
/*
Les classes canoniques : ni fuite ni trap
remarque préliminaire : cet article fait suite à "La classe vide est elle vide?"
http://www.cppfrance.com/article.aspx?Val=1080

pour qu'une classe soit utilisable, elle doit respecter certains principes
Masquage, encapsulation, identité, forte cohésion, ...
le masquage est le fait de cacher son implémentation, en particulier tous ses champs seront privés
l'encapsulation est le fait de permettre un accès contrôlé aux champs privés à travers des méthodes publiques (accesseurs et mutateurs)
etc...
En C++, il faut de plus veiller à ce que notre classe ne provoque pas de fuite mémoire ni de trap
Prenons un exemple

PARTIE I : une classe trop simple, mais moins qu'on le croit 
------------------------------------------------------------
la classe Personne portera le nom de la personne et permettra la modification et la lecture du nom
voici la première version :

  • /
#if N == 1 #include <iostream> using namespace std; class Personne { private: char pNom_[20];//on notera l'allocation NON dynamique de 20 caractères public: void setNom(char* pNom) { cout << "changement du nom de la Personne : " << pNom_ << " en " << pNom << endl; strcpy(pNom_, pNom);//Attention au dépassement de buffer !!!! } const char* getNom() { cout << "Lecture du nom de la Personne : " << pNom_ << endl; return pNom_; } }; int main() { Personne p1;//Ctor simple p1.setNom("Gwendoline");// cout << "nom de p1 = " << p1.getNom() << endl;//affiche 'Gwendoline' return 0; }//Appel du Destructeur /* Les experts C++ et ceux qui ont lu l'article précédent sur la classe vide peuvent répondre à la question suivante : Combien de méthodes de la classe Personne ont été appelées dans le code précédent Réponse ... du débutant : 2 (setNom et getNom) Réponse ... du connaisseur : 4 (le Constructeur, setNom, getNom et le Destructeur) On s'aperçoit ici que le compilateur a généré lui même les Constructeur/Destructeur Il n'a d'ailleurs pas fait un boulot formidable lors de la construction de notre objet car la zone de nom n'est pas initialisée. PARTIE II : une classe est naturellement canonique ----------------------------------------------- la classe Personne, encore simple, portera le nom de la personne permettra la lecture du nom et sa modification. Elle permettra de tracer les phases de construction et destruction des objets Comme on fournit nous même le constructeur, on en profitera pour initialiser le nom de la personne On cherchera à régler le problème de l'écrasement de buffer en tronquant le nom !
  • /
#elif N == 2 #include <iostream> using namespace std; class Personne { private: char pNom_[20];//on notera l'allocation NON dynamique de 20 caractères public: Personne(char * pNom="inconnu")//on met 'inconnu' dans le nom ... s'il n'est pas fourni { cout << "Construction de la Personne : " << pNom << endl; strncpy(pNom_, pNom, 20-1);//tronqué à 19 caractères utiles + 1 pour '\0' pNom_[20-1]=0; } ~Personne(){cout << "Destruction de la Personne " << pNom_ << endl;}; void setNom(char* pNom) { cout << "Changement du nom de la Personne : " << pNom_ << " en " << pNom << endl; strncpy(pNom_, pNom, 20-1);//tronqué à 19 caractères pNom_[20-1]=0; } const char* getNom() { cout << "Lecture du nom de la Personne : " << pNom_ << endl; return pNom_; } }; int main() { Personne p1("Saluche MoaJaiUnNomTropLong");//Ctor simple avec un nom trop long cout << p1.getNom() << endl;//affiche 'Saluche MoaJaiUnNom' (tronqué) p1.setNom("Gwendoline");// cout << "nom de p1 = " << p1.getNom() << endl;//affiche 'Gwendoline' //création d'une personne p2 Personne p2; cout << "nom de p2 = " << p2.getNom() << endl;// affiche 'inconnu' //utilisation de l'opérateur d'affectation p2 = p1; cout << "nom de p2 = " << p2.getNom() << endl;//affiche 'Gwendoline' //Céation d'une personne p3 à partir d'une personne p1 //Utilisation du Constructeur de copie Personne p3 = p1; cout << "nom de p3 = " << p3.getNom() << endl;//affiche 'Gwendoline' return 0; } /* Avez vous essayé de suivre l'exécution pas à pas du code dans le débogueur ? On remarquera que l'on ne peut pas tracer la construction de p3 ! et pourtant elle s'effectue bien... et l'objet est même détruit correctement ! EXPLICATION : le compilateur génère pour nous un constructeur de Copie dont le prototype est Personne(const Personne& p) On ne peut pas non plus tracer l'affectation lors de 'p2=p1' EXPLICATION : le compilateur génère de lui-même un opérateur d'affectation dont le prototype est Personne& operator=(const Personne& p) On peut donner maintenant la définition d'une classe canonique C'est une classe qui possède les 4 méthodes vues précédemment... et qui font bien leur travail !!! Contructeur / Constructeur de copie / opérateur d'affectation / Destructeur Nous voyons qu'une classe est naturellement canonique car le compilateur génère lui-même les méthodes qui manquent Qu'est ce donc qu'une classe NON canonique, comment peut on en arriver là ? PARTIE III : une classe DANGEREUSE (à rendre manuellement canonique) -------------------------------------------------------------------- Le problème avec la classe Personne est la limite à 20 (-1) caractères du nom. Bien sûr, on peut toujours décider de modifier le 20 en 50 , mais alors quel gâchis pour la pluspart des personnes qui ont un nom compris entre 5 et 10 caractères La solution est de rendre cette zone dynamique. On représentera donc le nom par un simple pointeur, qui pointera vers une zone de taille strictement adaptée au nom de chacun Notons que l'on peut mettre un pointeur null dans le cas ou le nom est inconnu Il s'agit donc de remplacer la déclaration char pNom_[2]; par la déclaration suivante : char *pNom; Est ce bien tout ce qu'il faut faire ? Bien sûr que non, il faut maintenant allouer et libérer correctement la mémoire. ATTENTION : les allocations/libérations doivent être correctes pendant toutes la durée de vie de l'objet ET NON PAS SEULEMENT LORS DE LA CONSTRUCTION ET DE SA DESTRUCTION On s'attachera particulièrement à suivre l'exécution de la bande des 4 qui forment une classe canonique
  • /
#elif N == 3 #include <iostream> using namespace std; class Personne { private: char * pNom_;//on notera l'allocation dynamique (à faire lors des constructions d'objets) public: Personne(char * pNom=0)//on met NULL dans le nom ... s'il n'est pas fourni { cout << "Construction de la Personne : " ; if (pNom) { cout << pNom << endl; pNom_ = new char [strlen(pNom)+1]; strcpy(pNom_, pNom);//non tronqué ... ajusté } else { pNom_ = 0; cout <<" <sans nom>"<<endl; } } ~Personne() { cout << "Destruction de la Personne "; if (pNom_) { cout << pNom_ << endl; delete[] pNom_;//le delete[] est nécessité par l'utilisation du new[] pNom_ = 0; } else cout <<" <sans nom>"<<endl; }; void setNom(char* pNom) { cout << "Changement du nom de la Personne : "; if (pNom_) { cout << pNom_ ; delete[] pNom_;//le delete[] est nécessité par l'utilisation du new[] } else cout <<" <sans nom>"; if (pNom) { cout << " en " << pNom << endl; pNom_ = new char [strlen(pNom)+1]; strcpy(pNom_, pNom);//non tronqué ... ajusté } else cout <<" en <sans nom>" << endl; } const char* getNom() { cout << "Lecture du nom de la Personne : "; if (pNom_) { cout << pNom_ << endl; return pNom_; } else { cout <<" <sans nom>" << endl; return "<sans nom>"; } } }; int main() { Personne p1("Saluche MoaJaiUnNomQuiestOK");//Ctor simple avec un nom très long cout << p1.getNom() << endl;//affiche 'Saluche MoaJaiUnNomQuiestOK' p1.setNom("Gwendoline");// cout << "nom de p1 = " << p1.getNom() << endl;//affiche 'Gwendoline' //création d'une personne p2 Personne p2; cout << "nom de p2 = " << p2.getNom() << endl;// affiche 'inconnu' //utilisation de l'opérateur d'affectation p2 = p1;//A commenter pour éviter le TRAP cout << "nom de p2 = " << p2.getNom() << endl;//affiche 'Gwendoline' //Céation d'une personne p3 à partir d'une personne p1 //Utilisation du Constructeur de copie Personne p3 = p1;//A commenter pour éviter le TRAP cout << "nom de p3 = " << p3.getNom() << endl;//affiche 'Gwendoline'//A commenter pour éviter le TRAP return 0; } /* Exécutez cette version et notez le TRAP provoqué lors de la destruction des objets Remarque : Ce trap est apparent avec VisualC++ mais pas avec BorlandC++ Il est de toutes façons nécessaire de revoir notre code pour rendre la classe canonique Que se passe t il ? Il s'agit tout simplement d'une double libération mémoire EXPLICATION : prenons le code simple suivant: { Personne p1("Bob"), p2; p2 = p1; } Lorsque p1 est créé, le compilateur alloue uniquement la mémoire correspondant au pointeur c'est notre code qui alloue les 4 octets nécessaires pour recevoir 'B' 'o' 'b' '\0' Idem pour p2, sauf que 0 octets sont alloués pour le nom (qui n'est pas renseigné) Lors de l'exécution de p2=p1, c'est le code généré par le compilateur (operator=) qui fait le boulot Il appelle l'opérateur = pour chacun des membres de notre classe (le pointeur donc) Comment fait il ? il copie simplement la VALEUR du pointeur pNom_ de la personne p1 sur le pointeur pNom_ de la personne p2 ... et ce boulot est mal fait car maintenant p1 et p2 référencent la même zone mémoire Lors de la destruction de p2, la zone mémoire est détruite et lorsque p1 doit être détruit, on cherche à détruire une zone mémoire qui n'est plus allouée à notre process ... d'où le TRAP ! Remarquons que des choses bizarres peuvent arriver lors du changement de nom de p1, etc... Notre classe n'est donc plus canonique et nous devons ABSOLUMENT définir notre opérateur = L'explication est à peu près identique pour le constructeur de copie Il est intéressant de noter que la suppression des lignes utilisant l'opérateur = et le Ctor de copie permet d'éviter le TRAP... c'est à dire de ne pas s'en apercevoir au moment où l'on livre cette classe dans une bibliothèque de production Attention donc à bien RAJOUTER ces appels à notre jeu de test AVANT la livraison pour éviter les surprises On pourrait se dire que la construction par la technique Personne p2 = p1; n'est pas fréquente Et qu'il n'est pas très utile de surcharger ce Ctor de copie Mais il est aussi utilisé dès que l'on fait des passages de paramètres par valeur (en argument ou en retour de fonction) On peut aussi supprimer les lignes de delete[] pour éviter les TRAPs mais alors on fait des fuites mémoire C'est parfois une option de livraison rapide d'une version béta... qui doit ensuite être corrigée pour la final release. Voici la classe corrigée PARTIE IV : une classe CORRECTE du point de vue de la canonicité ------------------------------------------------------------------
  • /
#elif N == 4 #include <iostream> using namespace std; class Personne { private: char * pNom_;//on notera l'allocation dynamique (à faire lors des constructions d'objets) public: Personne(char * pNom=0)//on met NULL dans le nom ... s'il n'est pas fourni { cout << "Construction de la Personne : " ; if (pNom) { cout << pNom << endl; pNom_ = new char [strlen(pNom)+1]; strcpy(pNom_, pNom);//non tronqué ... ajusté } else { pNom_ = 0; cout <<" <sans nom>"<<endl; } } //Constructeur de copie Personne(const Personne& p ) { cout << "Construction de la Personne : " ; if (p.pNom_) { cout << p.pNom_ << endl; pNom_ = new char [strlen(p.pNom_)+1]; strcpy(pNom_, p.pNom_);//non tronqué ... ajusté } else { pNom_ = 0; cout <<" <sans nom>"<<endl; } } ~Personne() { cout << "Destruction de la Personne "; if (pNom_) { cout << pNom_ << endl; delete[] pNom_;//le delete[] est nécessité par l'utilisation du new[] } else cout <<" <sans nom>"<<endl; }; //opérateur d'affectation Personne& operator=(const Personne& p ) { cout << "operateur d'affectation de la Personne : " ; //AUTO-AFFECTATION : si les deux objets sont les mêmes ... ne rien faire if (this == &p) return (*this); //destruction de l'ancienne zone de nom (si elle existe) if (pNom_) { cout << pNom_; delete[] pNom_; pNom_ = 0; } else { cout <<" <sans nom>"; } cout << " a partir de " ; if (p.pNom_) { cout << p.pNom_ << endl; pNom_ = new char [strlen(p.pNom_)+1]; strcpy(pNom_, p.pNom_);//non tronqué ... ajusté } else { pNom_ = 0; cout <<" <sans nom>"<<endl; } return *this; } void setNom(char* pNom) { cout << "Changement du nom de la Personne : "; if (pNom_) { cout << pNom_ ; delete[] pNom_;//le delete[] est nécessité par l'utilisation du new[] pNom_ = 0; } else cout <<" <sans nom>"; if (pNom) { cout << " en " << pNom << endl; pNom_ = new char [strlen(pNom)+1]; strcpy(pNom_, pNom);//non tronqué ... ajusté } else cout <<" en <sans nom>" << endl; } const char* getNom() { cout << "Lecture du nom de la Personne : "; if (pNom_) { cout << pNom_ << endl; return pNom_; } else { cout <<" <sans nom>" << endl; return "<sans nom>"; } } }; int main() { Personne p1("Saluche MoaJaiUnNomQuiestOKMaintenant");//Ctor simple avec un nom très long cout << p1.getNom() << endl;//affiche 'Saluche MoaJaiUnNomQuiestOKMaintenant' p1.setNom("Gwendoline");// cout << "nom de p1 = " << p1.getNom() << endl;//affiche 'Gwendoline' //création d'une personne p2 Personne p2; cout << "nom de p2 = " << p2.getNom() << endl;// affiche 'inconnu' //utilisation de l'opérateur d'affectation p2 = p1; cout << "nom de p2 = " << p2.getNom() << endl;//affiche 'Gwendoline' //Céation d'une personne p3 à partir d'une personne p1 //Utilisation du Constructeur de copie Personne p3 = p1; cout << "nom de p3 = " << p3.getNom() << endl;//affiche 'Gwendoline' p1.setNom(0); cout << "nom de p1 = " << p1.getNom() << endl;//affiche '<sans nom>' return 0; } /* Autre technique utilisable pour règler le problème du TRAP : interdire les copies et affectation C'est la parade la plus simple, car il suffit de déclarer (sans implémenter) l'opérateur et le Constructeur ... en private Alors, les développeurs utilisateurs de notre classe ne pourront plus Voici la classe corrigée ... avec ce contrôle plus directif ! PARTIE V : une autre classe CORRECTE du point de vue de la canonicité la méthode du paresseux ? ----------------------------------------------------------------------
  • /
#elif N == 5 #include <iostream> using namespace std; class Personne { private: char * pNom_; //Constructeur de copie privé Personne(const Personne& p ); //opérateur d'affectation privé Personne& operator=(const Personne& p ); public: Personne(char * pNom=0) { cout << "Construction de la Personne : " ; if (pNom) { cout << pNom << endl; pNom_ = new char [strlen(pNom)+1]; strcpy(pNom_, pNom); } else { pNom_ = 0; cout <<" <sans nom>"<<endl; } } ~Personne() { cout << "Destruction de la Personne "; if (pNom_) { cout << pNom_ << endl; delete[] pNom_;//le delete[] est nécessité par l'utilisation du new[] } else cout <<" <sans nom>"<<endl; }; void setNom(char* pNom) { cout << "Changement du nom de la Personne : "; if (pNom_) { cout << pNom_ ; delete[] pNom_;//le delete[] est nécessité par l'utilisation du new[] pNom_ = 0; } else cout <<" <sans nom>"; if (pNom) { cout << " en " << pNom << endl; pNom_ = new char [strlen(pNom)+1]; strcpy(pNom_, pNom);//non tronqué ... ajusté } else cout <<" en <sans nom>" << endl; } const char* getNom() { cout << "Lecture du nom de la Personne : "; if (pNom_) { cout << pNom_ << endl; return pNom_; } else { cout <<" <sans nom>" << endl; return "<sans nom>"; } } }; int main() { Personne p1("Saluche MoaJaiUnNomQuiestOKMaintenant");//Ctor simple avec un nom très long cout << p1.getNom() << endl;//affiche 'Saluche MoaJaiUnNomQuiestOKMaintenant' p1.setNom("Gwendoline");// cout << "nom de p1 = " << p1.getNom() << endl;//affiche 'Gwendoline' //création d'une personne p2 Personne p2; cout << "nom de p2 = " << p2.getNom() << endl;// affiche 'inconnu' //utilisation de l'opérateur d'affectation => IMPOSSIBLE p2 = p1;//à commenter pour retirer l'erreur de compil //Céation d'une personne p3 à partir d'une personne p1 //Utilisation du Constructeur de copie => IMPOSSIBLE Personne p3 = p1;//à commenter pour retirer l'erreur de compil return 0; } #endif /* RESUME : quand s'inquiter et que faire ? ----------------------------------------- Il faut se préoccuper de ce problème dès que l'on emploie des pointeurs dans notre classe Alors on ajoutera à notre jeu de test (qui appelle toutes les méthodes de la classe) les appels aux méthodes invisibles générées par le compilateur En particulier : le Constructeur par défaut (s'il existe) le Destructeur le Constructeur de copie l'Opérateur d'affectation Lorsque les emplacements pointés sont gérés par nous mêmes (et non pas directement par le compilateur), il faut se poser la question de savoir s'ils ont des contenus partageables ou non Dans notre exemple, le nom de chaque personne lui appartient, et il peut en changer sans que le nom d'une autre personne en soit affecté. On rendra donc canonique notre classe par l'implémentation correcte de l'opérateur d'affectation et du constructeur de copie S'il y a partage possible, la solution consiste à développer un système de comptage de références dont le but est d'éviter la destruction multiple ... mais ceci dépasse largement le cadre de cet article.
  • /

A voir également

Vous n'êtes pas encore membre ?

inscrivez-vous, c'est gratuit et ça prend moins d'une minute !

Les membres obtiennent plus de réponses que les utilisateurs anonymes.

Le fait d'être membre vous permet d'avoir un suivi détaillé de vos demandes et codes sources.

Le fait d'être membre vous permet d'avoir des options supplémentaires.