Étude autour des classes du C++
- par
Gilles Louise
La POO (Programmation Orientée Objet) tourne autour de la notion de "classe", un objet au sens de la POO sera toujours une classe. C’est cette notion qui caractérise essentiellement le C++, elle est une extension de la notion de "structure" du C. Dans une structure, tous les éléments étaient accessibles de l’extérieur alors que dans une classe, il y a cette possibilité d’"encapsuler" des données, elles ne sont alors plus accessibles de l’extérieur, elles sont comme protégées, c’est ce qui la différencie d’une structure. On utilise le mot réservé "private" pour désigner des éléments inaccessibles de l’extérieur et "public" pour désigner des éléments accessibles.
Pour déclarer une classe, c’est très simple, on utilise le mot réservé "class", on donne un nom à la classe, on définit la classe entre ses accolades et on termine par un point-virgule.
class Chose
{
/* définition de la classe */
};
N’oubliez pas le point-virgule final sinon il y aura une erreur à la compilation.
L’intérêt d’une classe est de pouvoir assembler des éléments liés logiciellement. Ces éléments sont appelés "membres", on parle donc de membres d’une classe. Il y a deux types de membres : les données et les fonctions. En POO pure, les données membres c’est-à-dire la liste des variables appartenant à la classe doivent être encapsulées donc inaccessibles directement de l’extérieur, on n’accède à ces données membres que par les fonctions de la classe, ces fonctions sont dites "méthodes", on appelle donc méthode toute fonction membre d’une classe.
Une classe a la possibilité d’avoir un constructeur et un destructeur, c’est une façon de vous donner la main au moment de l’instanciation de la classe et aussi au moment de sa destruction. On appelle " instanciation " la création d’un objet en mémoire. Constructeur et destructeur sont donc des méthodes particulières, ce sont des fonctions membres de la classe, on les met dans la section "public" puisqu’ils sont accessibles, ils se reconnaissent au fait qu’ils portent le même nom que la classe, nom tel quel pour le constructeur et précédé du tilde (~) pour le destructeur.
Imaginons une application qui traite des fichiers. Ces fichiers vont être lus en mémoire, traités puis sauvegardés. Une fois lu en mémoire, un fichier a deux caractéristiques, une adresse à partir de laquelle se situe le fichier et une longueur, ce qui se concrétisera par un pointeur et une longueur en nombre d’octets. Imaginons la classe "Fichier" avec un constructeur et un destructeur comme suit :
class Fichier
{
char* P;
unsigned int Lg;
public:
Fichier();
~Fichier();
};
Par défaut les éléments d’une classe sont du type "private", donc le pointeur P et le nombre entier Lg sont des données encapsulées mais bien entendu on peut écrire aussi "private:" juste avant ces données, on peut même alterner les sections "private" et "public". Vient ensuite la section "public" avec la déclaration du constructeur et du destructeur. Il s’agit simplement de la déclaration du prototype de ces fonctions, il faut maintenant les écrire. Le constructeur va simplement initialiser P à NULL et la longueur Lg à 0 et le destructeur va libérer la mémoire censément pointée par P. L’initialisation à NULL du pointeur P est toujours préférable car elle permet un bon fonctionnement de l’instruction delete du destructeur même si P est toujours à NULL au moment du delete c’est-à-dire même s’il n’y a pas eu allocation mémoire à partir de P (car si P n’était pas initialisé, le delete planterait). En initialisant P à NULL donc, on s’assure de ne pas faire planter le delete, qu’il y ait eu allocation mémoire ou non.
Fichier::Fichier()
{
P=NULL;
Lg=0;
}
Fichier::~Fichier()
{
delete P;
}
Remarquez que le constructeur et le destructeur n’ont aucun type (même pas void qui serait refusé par le compilateur), que le point-virgule n’est pas nécessaire après l’accolade fermée contrairement à la déclaration d’une classe et que toute méthode est préfixée par le nom de la classe suivi de deux points (ici Fichier::).
Imaginons maintenant trois méthodes nouvelles, la méthode "Creation" qui va allouer un certain espace à partir du pointeur P, la méthode "Remplit" qui va remplir arbitrairement cet espace (ces remplissages arbitraires sont la preuve de la bonne gestion mémoire car l’accès à une zone non déclarée provoque une violation d’accès) et la méthode "Affiche" qui va afficher la zone mémoire pointée par P. Puis écrivons un programme maître qui instancie notre classe par new, appelle nos trois méthodes et détruit l’objet par delete.
#include <iostream.h>
// déclaration de la classe Fichier
class Fichier
{
char* P;
unsigned int Lg;
public:
Fichier();
~Fichier();
bool Creation(unsigned int);
void Remplit();
void Affiche();
};
// constructeur
Fichier::Fichier()
{
P=NULL;
Lg=0;
}
// destructeur
Fichier::~Fichier()
{
delete P;
}
// méthode Creation
bool Fichier::Creation(unsigned int L)
{
if((P=(char*)malloc(L))==NULL) return false;
Lg=L;
return true;
}
// Méthode Remplit
void Fichier::Remplit()
{
for(unsigned int i=0;i<Lg;i++) P[i]='a';
}
// Méthode Affiche
void Fichier::Affiche()
{
for(unsigned int i=0;i<Lg;i++) cout<<P[i];
}
//-----Programma maître (main)--------------
void main(void)
{
Fichier* f=new Fichier();
if (f->Creation(10))
{
f->Remplit();
f->Affiche();
}
delete f;
}
Pour nos tests, nous utilisons le
compilateur gratuit C++ de Borland que vous pouvez télécharger à discrétion.
Le programme maître crée d’abord un pointeur vers notre classe "Fichier" puis instancie par new cette classe. C’est à ce moment, au moment du new, que le constructeur de classe est appelé. Ensuite on appelle la méthode " Creation ", laquelle renvoie un booléen à true si la création a été acceptée, false sinon. Si la création est acceptée, on remplit alors la zone allouée (remplissage arbitraire avec le code ASCII de la lettre a), ensuite on l’affiche, ceci nous prouve d’une part la correction de l’allocation et d’autre part son accessibilité (sinon il y aurait une violation d’accès).
Vous remarquez que toutes les méthodes ont accès au pointeur P et à l’entier Lg mais ces variables ne sont pas accessibles depuis le programme maître. Si par exemple Ptr est un pointeur de type char*, on ne pourrait pas écrire :
Ptr=f->P;
le compilateur nous dirait que Fichier::P (lire " le membre P de la classe Fichier ") n’est pas accessible dans la fonction main (i.e. le programme maître). C’est ce qu’on entend par " encapsulation ", P est encapsulé, il n’est visible que depuis les méthodes de la classe. Idem pour Lg et pour toutes données situées dans la section " private ".
Remarquez que pour l’allocation mémoire dans la méthode " Creation " nous avons utilisé l’instruction malloc du C pur et non pas new du C++. La raison en est que le refus de l’allocation s’analyse par les exceptions avec new alors que malloc se contente de renvoyer NULL si l’allocation est refusée. En utilisant new, nous aurions été obligé de le protéger par le couple try/catch et écrit par exemple :
bool Fichier::Creation(unsigned int L)
{
try
{
P=new char[L];
}
catch(...)
{
return false;
}
Lg=L;
return true;
}
En effet, bien que P soit initialisé à NULL par le constructeur de la classe, on ne peut pas tester P après " new " pour savoir s’il est toujours à NULL car si l’allocation par new est refusée, le programme s’arrêtera de lui-même puisqu’en cas d’exception C++ Windows reprend la main et affiche une fenêtre indiquant une violation d’accès (il y a paraît-il une possibilité avec la spécification __declspec(nothrow), par exemple int* a=new __declspec(nothrow)int[N] mais je n'ai pas réussi à la mettre en oeuvre avec C++ Builder, normalement, dans ces conditions, si N est trop grand, on devrait avoir a=NULL comme si on avait utilisé malloc, nothrow étant censé signifier que les exceptions ne sont pas déclenchées). S’agissant de l’instruction new, il y a donc deux possibilités : ou bien il faut procéder par try/catch comme dans l’exemple précédent, ou bien on considère qu’il ne peut pas y avoir de problème d’allocation mémoire, auquel cas on ne fait aucun test, par exemple :
#include <iostream.h>
class Fichier
{
char* P;
unsigned int Lg;
public:
Fichier();
~Fichier();
void Creation(unsigned int);
void Remplit();
void Affiche();
};
Fichier::Fichier()
{
cout<<"construction\n";
P=NULL;
Lg=0;
}
Fichier::~Fichier()
{
cout<<"destruction\n";
free(P);
}
void Fichier::Creation(unsigned int L)
{
P=new char[L];
Lg=L;
}
void Fichier::Remplit()
{
for(unsigned int i=0;i<Lg;i++) P[i]='a';
}
void Fichier::Affiche()
{
for(unsigned int i=0;i<Lg;i++) cout<<P[i];
cout<<endl;
}
//--------------------------------------
void main(void)
{
Fichier* f=new Fichier();
f->Creation(10);
f->Remplit();
f->Affiche();
delete f;
}
Dans ce cas, c’est Windows qui prend la main en cas d’erreur. La méthode " Creation " n’est plus booléenne, elle ne renvoie pas d’indication sur l’autorisation d’allocation, le main (programme maître) ne fait que l’appeler sans tester de code retour puisqu’il n’y en a plus (en bleu, les différences avec la programmation précédente).
La règle serait la suivante : l’instanciation par new a deux caractéristiques, d’une part elle s’opère sur un objet fixe (par exemple une classe en elle-même a une taille fixe) et d’autre part on ne teste pas la validité de l’instanciation et ce, parce qu’au plan probabilitaire, il n’est pas possible que l’instanciation soit refusée. En revanche, on continue l’utilisation de l’instruction malloc du C pur s’agissant d’une allocation dynamique d'une part parce qu’il est très aisé de tester le refus de l’allocation (puisqu’en cas de refus malloc renvoie NULL), d'autre part parce qu'il y a possibilité d'un realloc notamment pour agrandir la zone, ce qui est très pratique surtout pour les zones mémoire ou zone de pointeurs (notamment pour les pointeurs de classes). Dans l’absolu, il faudrait toujours préférer le couple new/delete du C++ à l’obsolète malloc/free du C pur mais c'est plus facile à dire qu'à faire car le malloc a son realloc, ce qui n'est pas le cas du new. Si toutefois vous travaillez avec C++Builder, vous disposez alors d’objets sophistiqués tels que les AnsiString, les TSmallIntArray (voir
alinéa 43 de mes "Remarques") ou encore les TList (alinéa 47) qui résolvent la question et gèrent parfaitement la mémoire à la mode C++. Bien entendu, on perd légèrement en performance puisqu’on s’est éloigné du système mais on gagne en qualité et sécurité car la gestion mémoire est parfaite.
Observons que la mémoire allouée ne fait pas partie de la classe en tant que telle, la classe contient seulement un pointeur initialisé à NULL à la construction et pointant ensuite une allocation mémoire.
La classe a été instanciée comme suit :
Fichier* f=new Fichier();
Cette instruction est en fait une double instruction puisqu’il y a d’une part la déclaration du pointeur f et d’autre l’instanciation de la classe "Fichier". On aurait d’ailleurs pu écrire en deux lignes :
Fichier* f;
f=new Fichier();
Cela dit, nous aurions pu instancier autrement en déclarant simplement un objet du type "Fichier" par exemple :
Fichier f;
Dans ce cas, la classe n’est plus instanciée dans le tas (on appelle ainsi la mémoire libre, c’est cette mémoire libre qui est invoquée par new ou malloc), elle est instanciée dans la pile. Il y aura par la suite deux différences, d’une part les membres de la classe vont être accessibles non plus par la petite flèche (objet instancié dans le tas) mais pas un point (objet instancié dans la pile), d’autre part il n’y aura plus de delete car c’est le programme qui se charge de la destruction de l’objet puisqu’il gère la pile. Cela dit, bien qu’il n’y ait pas de delete, le destructeur de la classe est quand même appelé, la mémoire allouée est donc restituée (par le destructeur puisque c’est là que nous avons programmé le delete) mais la classe en elle-même est libérée dans ce cas par la gestion de la pile. Le programme maître s’écrirait donc ainsi :
void main(void)
{
Fichier f;
f.Creation(10);
f.Remplit();
f.Affiche();
}
En général, on évite d’instancier ainsi une classe, la pile n’est pas tellement conçue pour accueillir des objets. Elle peut facilement accueillir quelques pointeurs et quelques variables mais on évite d’instancier ainsi une classe, a fortiori plusieurs. En revanche, on instancie dans la pile un pointeur vers une classe
Fichier* f;
puis on instancie dans le tas la classe en elle-même
f=new Fichier();
Dans le cas de tableaux de classes, vous pouvez instancier le tableau de pointeurs dans la pile
Fichier* f[NbCl];
où NbCl (nombre de classes) est une constante arbitraire puis instancier les NbCl classes dans le tas par une boucle, par exemple :
void main(void)
{
Fichier* f[NbCl];
for(int i=0;i<NbCl;i++) f[i]=new Fichier();
for(int i=0;i<NbCl;i++)
{
f[i]->Creation(10);
f[i]->Remplit();
f[i]->Affiche();
}
for(int i=0;i<NbCl;i++) delete f[i];
}
Remarquez aussi la boucle finale qui détruit les NbCl classes. Une erreur serait d'écrire delete[]f, cette écriture ne convient que si la création s'est faire par new[], à delete correspond new, à delete[] correspond new[]. L'écriture delete[]f aurait correspondu à une instanciation des classes par Fichier *f=new Fichier[NbCl]. Mais l’inconvénient d’une telle écriture est que seul un constructeur par défaut peut être appelé (jamais un autre) alors qu’en passant par un pointeur pour chaque classe (c’est la règle que nous proposons), on a le choix du constructeur.
Cela dit, instancier NbCl pointeurs n’est acceptable que s’il y a peu de pointeurs car c’est la pile qui accueille ce tableau de pointeurs, il est toujours préférable de ne pas surcharger la pile. Le mieux donc serait d’instancier en pile non pas un tableau de pointeurs mais un pointeur de pointeurs de classe (c’est-à-dire un pointeur vers tableau de pointeurs) :
Fichier** f;
Ainsi f est ici un pointeur vers un tableau de pointeurs de classe. Maintenant on instancie dans le tas ce tableau de pointeurs
f=new Fichier*[NbCl];
Maintenant seul le pointeur f est en pile, le tableau de pointeurs est dans le tas, la constante NbCl peut maintenant être très grande puisque ce tableau se trouve dans le tas. Le reste du programme serait identique.
void main(void)
{
Fichier** f;
f=new Fichier*[NbCl];
for(int i=0;i<NbCl;i++) f[i]=new Fichier();
for(int i=0;i<NbCl;i++)
{
f[i]->Creation(10);
f[i]->Remplit();
f[i]->Affiche();
}
for(int i=0;i<NbCl;i++) delete f[i];
delete f;
}
Il en sera en général ainsi, pas de tableau de pointeurs en pile mais un pointeur unique qui pointe le tas qui contient toute la structure quelque complexe qu’elle soit, cela ne coûte qu’un niveau d’indirection en plus, une étoile de plus dans la déclaration du pointeur.
Imaginons maintenant que vous ayons NbCl classes à traiter. Il serait judicieux d’avoir en mémoire un pointeur (char*) et une longueur (unsigned int) qui soit une copie des mêmes éléments privés de l’instance active à un moment donné (pensez par exemple au logiciel Word qui n’a qu’un seul document actif à un moment donné bien qu’il peut en ouvrir plusieurs simultanément) :
char *PtrFic;
unsigned int Lg;
Ainsi, si nous avons instancié NbCl classes du type " Fichier " et si f[i] est la classe active à un moment donné, on fera en sorte que PtrFic soit égal à f[i]->P et Lg à f[i]->Lg. L’intérêt d’une telle démarche est de pouvoir agir sur une zone allouée sans passer par une méthode de classe. Les variables membres restent encapsulées, PtrFic et Lg ne seront que des copies des données membres de l’instance active, on peut ainsi agir sur cette mémoire. Dans ces conditions, on modifierait la méthode Creation en lui donnant en plus la référence du pointeur char* du programme maître.
void Fichier::Creation(unsigned int L, char*& Pointeur)
{
P=new char[L];
Lg=L;
Pointeur=P;
}
Ainsi, on alloue à partir de P la mémoire mais on renvoie ce pointeur à l’appelant. Dans ce cas, si PtrFic est le pointeur char* du programme maître, il pointera l’instance active à un moment donné.
void Affiche(char* P, unsigned int L)
{
for(unsigned int i=0;i<L;i++) cout<<*P++;
cout<<endl;
}
//--------------------------------------
const int NbCl=3;
void main(void)
{
Fichier** f;
char *PtrFic;
unsigned int Lg;
f=new Fichier*[NbCl];
for(int i=0;i<NbCl;i++) f[i]=new Fichier();
for(int i=0;i<NbCl;i++)
{
Lg=10;
f[i]->Creation(Lg,PtrFic);
f[i]->Remplit();
Affiche(PtrFic,Lg);
}
for(int i=0;i<NbCl;i++) delete f[i];
delete f;
}
Ici la méthode Creation renvoie le pointeur à PtrFic qui pointe l’allocation de l’instance active. On le prouve en appelant la fonction Affiche qui a pour argument PtrFic et la longueur allouée. L’intérêt d’avoir une copie des données membres est de pouvoir maintenant agir sur l’allocation d’une instance active sans avoir à créer de nouvelles méthodes. Affiche est ici une fonction isolée, elle accède à la mémoire de l’instance active sans être une méthode de la classe Fichier car il n’y a aucune raison pour gonfler une classe en lui rajoutant des fonctions membres.
Cela dit, cette méthodologie n’est peut-être pas encore parfaite car Lg n’est qu’une variable du programme maître sans lien avec l’instance active. Imaginons par exemple que la mémoire pointée par PtrFic (et donc par f[i]->P) ait à être augmentée par un realloc, il faudrait prévoir une méthode qui renvoie f[i]->Lg pour l’affecter à Lg du programme maître. Il faudrait d’ailleurs faire de même pour PtrFic car après un realloc, il se peut que la zone allouée change de place. Il faudra donc que PtrFic pointe non plus la zone allouée susceptible de changer d’emplacement mais le pointeur lui-même dans l’instance de classe qui, lui, ne bouge pas. Il faut donc rajouter un niveau d’indirection, Lg deviendra un unsigned int* et PtrFic un char**. Lg pointera donc l’unsigned int du tas (Lg n’est alloué qu’une seule fois ce pourquoi on peut pointer le tas) et PtrFic pointera le char* de l’instance de classe. Dans ces conditions, la longueur de la zone ne sera plus Lg mais *Lg. Lors de la méthode Creation, on renverra &P à l’appelant c’est-à-dire l’adresse de P (dont le contenu pointe la zone allouée).
#include <iostream.h>
// déclaration de la classe Partition
class Partition
{
char* P;
unsigned int* Lg;
public:
Partition(unsigned int*&);
~Partition();
void Creation(unsigned int, char**&);
void Remplit();
void Allonge(unsigned int A);
};
// Constructeur
Partition::Partition(unsigned int*& L)
{
P=NULL;
Lg=new unsigned int;
L=Lg;
}
// Destructeur
Partition::~Partition()
{
delete P;
delete Lg;
}
// Creation d'une zone dynamique
void Partition::Creation(unsigned int N, char**& Ptr)
{
*Lg=N;
P=new char[*Lg];
Ptr=&P;
}
// Remplissage arbitraire
void Partition::Remplit()
{
for(unsigned int i=0;i<*Lg;i++) P[i]='a';
}
// Allongement de la zone
void Partition::Allonge(unsigned int A)
{
P=(char*)realloc(P,*Lg+=A);
}
//--------------------------------------
void Affiche(char** Pointeur, unsigned int* L)
{
char* P=*Pointeur;
for(unsigned int i=0;i<*L;i++) cout<<*P++;
cout<<endl;
}
//--------------------------------------
const int NbCl=3, bloc=10;
void main(void)
{
Partition **a;
char** PtrFic;
unsigned int* Lg;
a=new Partition*[NbCl];
for(int i=0;i<NbCl;i++)
{
a[i]=new Partition(Lg);
a[i]->Creation(bloc,PtrFic);
a[i]->Remplit();
Affiche(PtrFic,Lg);
a[i]->Allonge(bloc);
a[i]->Remplit();
Affiche(PtrFic,Lg);
}
for(int i=0;i<NbCl;i++) delete a[i];
delete a;
}
Le constructeur a cette fois-ci un argument à savoir Lg (pointeur de l’unsigned int). Il crée par new cet unsigned int dans le tas et renvoie l’adresse à l’appelant. La méthode Allonge reçoit en argument le nombre d’octets à ajouter à la zone allouée. Comme PtrFic pointe non pas cette zone du tas mais le char* de l’instance de classe, il n’y aura aucun problème si la nouvelle allocation change de place dans la mémoire libre. Comme Lg pointe l’unsigned int du tas, *Lg sera égal à la nouvelle longueur de la zone allouée. La fonction libre Affiche reçoit maintenant ce char**, elle commence par lire le contenu pointé
(char* P=*Pointeur;), dans ces conditions P pointe maintenant la zone allouée dans le tas. Ainsi à tout moment, PtrFic et Lg sont en relation avec l’instance active, en cas de changement, le programme maître est comme " au courant " de ces modifications à cause du niveau d’indirection.
Lg du programme maître pointe directement l’unsigned int du tas car ce nombre n’est pas susceptible de réallocation mais PtrFic du main pointe le PtrFic de la classe, en cas de changement de position, le programme maître est " au courant " puisqu’il lit dans l’instance de la classe active. On peut vérifier ce point en bouclant aussi longtemps qu’il n’y a pas eu modification de l’emplacement mémoire. Ainsi, on déclare PMem du type char* (mémoire du pointeur), on mémorise dans PMem l’adresse de la zone mémoire et on appelle la méthode Allonge aussi longtemps qu’il n’y a pas eu de modification d’emplacement.
void main(void)
{
Partition **a;
char** P, *PMem;
unsigned int* L;
a=new Partition*[NbCl];
for(int i=0;i<NbCl;i++)
{
a[i]=new Partition(L);
a[i]->Creation(bloc,P);
a[i]->Remplit();
Affiche(P,L);
PMem=*P;
while(PMem==*P) a[i]->Allonge(bloc);
a[i]->Remplit();
Affiche(P,L);
}
for(int i=0;i<NbCl;i++) delete a[i];
delete a;
}
Par ce petit test, on force un changement d’allocation et on constate que ça marche parfaitement. Si maintenant on ne veut pas manipuler comme dans l’exemple précédent un niveau d’indirection supplémentaire, il faut alors prévoir une méthode qui renverra le pointeur et la longueur de l’instance demandée. C’est peut-être une solution valable, elle a l’avantage de plus de clarté au sens où on a la même déclaration dans le programme maître que dans la classe.
#include <iostream.h>
// déclaration de la classe Partition
class Partition
{
char* P;
unsigned int Lg;
public:
Partition();
~Partition();
void Creation(unsigned int);
void Remplit();
void Allonge(unsigned int A);
void DonnePL(char*&, unsigned int&);
};
// Constructeur
Partition::Partition()
{
P=NULL;
Lg=0;
}
// Destructeur
Partition::~Partition()
{
delete P;
}
// Creation d'une zone dynamique
void Partition::Creation(unsigned int N)
{
Lg=N;
P=new char[Lg];
}
// Remplissage arbitraire
void Partition::Remplit()
{
for(unsigned int i=0;i<Lg;i++) P[i]='a';
}
// Allongement de la zone
void Partition::Allonge(unsigned int A)
{
P=(char*)realloc(P,Lg+=A);
}
// donne le pointeur de la longueur de l'instance associé à l'appel
void Partition::DonnePL(char*& Pointeur, unsigned int& Longueur)
{
Pointeur=P;
Longueur=Lg;
}
//--------------------------------------
void Affiche(char* P, unsigned int L)
{
for(unsigned int i=0;i<L;i++) cout<<*P++;
cout<<endl;
}
//--------------------------------------
const int NbCl=3, bloc=10;
int main(void)
{
Partition **a;
char *PtrFic;
unsigned int Lg;
a=new Partition*[NbCl];
for(int i=0;i<NbCl;i++)
{
a[i]=new Partition();
a[i]->Creation(bloc);
a[i]->Remplit();
a[i]->DonnePL(PtrFic,Lg);
Affiche(PtrFic,Lg);
a[i]->Allonge(bloc);
a[i]->Remplit();
a[i]->DonnePL(PtrFic,Lg);
Affiche(PtrFic,Lg);
}
for(int i=0;i<NbCl;i++) delete a[i];
delete a;
}
Dans cette autre programmation, l’instance du programme maître (char* PtrFic et unsigned int Lg) est de même nature que celle de la classe Partition (char* P et unsigned int Lg). On a créé la méthode DonnePL qui renvoie P et Lg de l’instance associée à l’appel, donc PtrFic et Lg du programme maître sont ainsi mis à jour, on ne se pose ainsi plus la question de savoir si la réallocation a modifié ou non l’emplacement mémoire. Il semble que cette solution soit acceptable puisque si i est l’indice de l’instance active, il suffit d’écrire a[i]->DonnePL(PtrFic,Lg); pour mettre à jour PtrFic et Lg du programme maître en liaison à l’instance active.
Nous venons d’étudier le cas d’un fichier simple et n’avons eu à manipuler pour chaque instance de classe qu’un pointeur et une longueur de zone mémoire. Imaginons maintenant que le fichier à traiter se divise en différentes sections. Pensez par exemple aux fichiers MIDI. Ce sont des fichiers musicaux, ils se divisent en pistes. Simplifions en disant que chaque piste correspond à un instrument. Notre question est de savoir comment nous allons dans ce cas gérer la mémoire. Chaque piste ou voix va être lue et traitée puis écrite dans une première série de zones mémoire (autant de zones que de pistes). On va ensuite réanalyser chacune de ces zones et les recodifier autrement de manière à connaître la durée des notes et des silences. L’expérience montre que certaines pistes de la première série sont vides, elles n’auront donc pas à exister dans la deuxième série, il n’y aura donc peut-être pas autant de pistes dans la série 1 que dans la série 2. Quoi qu’il en soit, on voit qu’il va falloir gérer deux tableaux de pointeurs. Comme précédemment, le programme maître devrait avoir accès à tous les éléments d’une instance active. De plus, les tableaux de pointeurs devront être associés à des tableaux de longueurs car pour chaque piste de la série 1 ou de la série 2 il faudra connaître sa longueur pour pouvoir éventuellement l’allonger car durant la codification, on ne peut pas savoir à l’avance la longueur des zones.
#include <iostream.h>
class Partition
{
int s1,s2;
char **PM[2];
int *LM[2];
public:
Partition();
Partition(int,int);
~Partition();
void Donne(char***&, int**&, int&, int&);
void Remplit();
};
/* Constructeur simple, cas où l'on ne connaît pas encore
le nombre de zones des séries, on se contente de mettre
les variables à zéro. Dans ces conditions, les tableaux de pointeurs
n'existent pas encore et n'existeront peut-être jamais */
Partition::Partition()
{
s1=0;
s2=0;
}
/* Autre constructeur.
On reçoit ici en argument le nombre de zones pour les deux séries,
on crée donc les deux tableaux de pointeurs pour chacune des séries */
Partition::Partition(int NbSer1, int NbSer2)
{
s1=NbSer1;
s2=NbSer2;
PM[0]=new char*[s1];
LM[0]=new int[s1];
PM[1]=new char*[s2];
LM[1]=new int[s2];
}
/* création des zones avec des longueurs différentes
et remplissage arbitraire */
void Partition::Remplit()
{
for(int i=0;i<s1;i++)
{
LM[0][i]=(i+1)*3;
PM[0][i]=new char[LM[0][i]];
}
for(int i=0;i<s2;i++)
{
LM[1][i]=(i+1)*5;
PM[1][i]=new char[LM[1][i]];
}
for(int i=0;i<s1;i++) for(int j=0;j<LM[0][i];j++) PM[0][i][j]='a';
for(int i=0;i<s2;i++) for(int j=0;j<LM[1][i];j++) PM[1][i][j]='b';
}
/* Destructeur */
Partition::~Partition()
{
if(s1)
{
for(int i=0;i<s1;i++) delete PM[0][i];
delete PM[0];
delete LM[0];
}
if(s2)
{
for(int i=0;i<s2;i++) delete PM[1][i];
delete PM[1];
delete LM[1];
}
}
/* renvoie les éléments de l'instance associée à l'appel */
void Partition::Donne(char***& P,int**& L,int& serie1,int& serie2)
{
P=PM;
L=LM;
serie1=s1;
serie2=s2;
}
//--------------------------------------
const int NbCl=5;
int main(void)
{
Partition** a;
char ***P;
int** L, s1, s2;
/* Crée le tableau de pointeurs de classe */
a=new Partition*[NbCl];
/* instancie les NbCl objets */
for(int i=0;i<NbCl;i++)
{
a[i]=new Partition((i+1)*3,i+9);
a[i]->Remplit();
}
/* renvoie les éléments de l'instance d'indice 3 */
a[3]->Donne(P,L,s1,s2);
/* P pointe à lui seul toute la structure
on accède facilement à l'instance, cet affichage le prouve
puisqu'il accède aux s1 zones de la série P[0] et aux s2
zones de la série P[1] */
for(int i=0;i<s1;i++)
{
for(int j=0;j<L[0][i];j++)
cout<<P[0][i][j];
cout<<endl;
}
for(int i=0;i<s2;i++)
{
for(int j=0;j<L[1][i];j++)
cout<<P[1][i][j];
cout<<endl;
}
/* Détruit les NbCl classes instanciées */
for(int i=0;i<NbCl;i++) delete a[i];
delete a;
/* Nouvelle instanciation */
Partition* Z=new Partition();
/* puis destruction, ceci nous prouve que la destruction fonctionne
même si les tableaux (a fortiori les zones) n'ont pas été alloués */
delete Z;
}
Le programme maître gère ici quatre variables : int s1 qui représente le nombre de pistes de la série 1, int s2 le nombre de pistes de la série 2, char***P qui pointe la base de la structure et unsigned int **L pour les longueurs associées. Il y a donc ici deux séries, la série P[0] et la série P[1].
La série P[0] est un tableau de s1 pointeurs, on a donc P[0][0] pour le premier pointeur de P[0][s1-1] pour le dernier.
La série P[1] est un tableau de s2 pointeurs, on a donc P[1][0] pour le premier pointeur de P[1][s2-1] pour le dernier.
Á ces tableaux de pointeurs sont associées leurs longueurs. Ainsi toute piste P[x][y] a pour longueur L[x][y]. On voit que si la piste P[x][y] est réallouée, L[x][y] aura une nouvelle valeur. Les éléments de l’instance d’indice i se lise par l’instruction a[i]->Donne(P,L,s1,s2). On voit l’économie de ce type de programmation puisque le programme maître n’a que quatre variables, P, L, s1 et s2. Si l’on ajoute PtrFic et Lg des exemples précédents, on voit qu’on accède à tous les éléments d’une instance active au moyen d’un minimum de variables en pile.
Notez que le programme maître ne "sait" pas combien il y a de séries, P pointe la base d'une structure, c'est tout. Imaginons que dans la cours du développement il y ait besoin d'une troisième série (par exemple pour codifier les éléments graphiques d'affichage de la partition pour chacune des pistes), il suffirait de déclarer char**PM[3] et unsigned int *LM[3] dans la classe, du point de vue du "main", cela ne changerait rien, on aurait toujours char***P et int **L.
On pourrait nous objecter que dans nos exemples les membres privés des classes ne sont pas complètement encapsulés (ainsi que le préconise la POO pure) puisque le programme maître a tous les ingrédients pour intervenir sur une instance active tant en lecture qu'en écriture et même en réallocation. Nous répondons d'une part que les membres privés sont bien inaccessibles puisque dans le programme maître on ne pourrait pas écrire par exemple a[i]->PM ni a[i]->PM[0] ni a[i]->PM[0][i] et ainsi de suite (si "Partition" avait été une structure, ces syntaxes aurait été autorisées puisque par définition dans une structure tous les membres sont publics, la notion de "public" et "private" étant absente des structures du C). Bref, en aucun cas on n'accède directement aux variables d'une instance de classe. Nous répondons d'autre part que les zones mémoire allouées ne font pas partie de la classe en tant que telle, elle sont seulement associées à une instance de classe sans en être directement membres. En d'autres termes, s'il est en général préférable d'encapsuler, mon conseil serait de ne pas encapsuler l'encapsulation.
On voit aisément l'intérêt de ces fameuses classes. D'une part, on peut facilement gérer un tableau de pointeurs vers ces classes, d'autre part instancier à n'importe quel moment un objet de ce type. Dans l'exemple précédent, nous instanciions un nouvel objet ainsi : Partition* Z=new Partition(). On appelle ici un constructeur simple qui initialise à zéro s1 et s2 et ce, parce que l'on ne sait pas encore combien il y aura de pistes pour chacune des séries. Maintenant comment procéderions nous pour recopier dans l'instance Z une partition complètement renseignée? Pour ce faire, nous allons créer la méthode Recopie qui va avoir pour caractéristique curieuse de recevoir en argument un pointeur vers une instance de type "Partition".
void Partition::Recopie(Partition* W)
{
/* quatre variables pour accéder à l'instance envoyée W*/
char ***P;
int** L, ser1, ser2;
/* Initialisation des variables pour l'instance W */
W->Donne(P,L,ser1,ser2);
/* Ici PM, LM, s1 et s1 concernent l'instance de l'appelant alors
que P, L, ser1 et ser2 sont les données de l'instance W envoyée */
/* On renseigne le nombre de pistes pour chaque série*/
s1=ser1;
s2=ser2;
/* On crée les tableaux de pointeurs */
PM[0]=new char*[s1];
LM[0]=new int[s1];
PM[1]=new char*[s2];
LM[1]=new int[s2];
/* On recopie les longueurs de chaque zone et on alloue chaque zone
pour la première série */
for(int i=0;i<s1;i++)
{
LM[0][i]=L[0][i];
PM[0][i]=new char[LM[0][i]];
}
/* idem pour la deuxième série */
for(int i=0;i<s2;i++)
{
LM[1][i]=L[1][i];
PM[1][i]=new char[LM[1][i]];
}
/* on recopie les zones */
for(int i=0;i<s1;i++) for(int j=0;j<LM[0][i];j++) PM[0][i][j]=P[0][i][j];
for(int i=0;i<s2;i++) for(int j=0;j<LM[1][i];j++) PM[1][i][j]=P[1][i][j];
}
Ne pas oublier bien sûr de déclarer son prototype dans la classe.
class Partition
{
int s1,s2;
char **PM[2];
int *LM[2];
public:
Partition();
Partition(int,int);
~Partition();
void Donne(char***&, int**&, int&, int&);
void Remplit();
void Recopie(Partition *P);
};
Modifions le programme maître précédent pour vérifier. On ne détruit pas tout de suite les NbCl classes de manière à pouvoir initialiser Z avec la partition a[2] à titre d'essai.
void main(void)
{
Partition** a;
char ***P;
int** L, s1, s2;
/* Crée le tableau de pointeurs de classe */
a=new Partition*[NbCl];
/* instancie les NbCl objets */
for(int i=0;i<NbCl;i++)
{
a[i]=new Partition((i+1)*3,i+9);
a[i]->Remplit();
}
/* renvoie les éléments de l'instance d'indice 3 */
a[3]->Donne(P,L,s1,s2);
/* P pointe à lui seul toute la structure
on accède facilement à l'instance, cet affichage le prouve
puisqu'il accède aux s1 zones de la série P[0] et aux s2
zones de la série P[1] */
for(int i=0;i<s1;i++)
{
for(int j=0;j<L[0][i];j++)
cout<<P[0][i][j];
cout<<endl;
}
for(int i=0;i<s2;i++)
{
for(int j=0;j<L[1][i];j++)
cout<<P[1][i][j];
cout<<endl;
}
/* Nouvelle instanciation */
Partition* Z=new Partition();
/* On recopie dans Z la partition a[2] */
Z->Recopie(a[2]);
/* Détruit les NbCl classes instanciées */
for(int i=0;i<NbCl;i++) delete a[i];
delete a;
delete Z;
}
On voit donc que traitant un tableau d'objets, on a tout intérêt à disposer dans le programme maître d'un matériel minimal d'accès à une instance active. Le principe que nous proposons consiste à travailler avec des tableaux de pointeurs et des tableaux de longueurs. Le programme maître n'a qu'un pointeur à la racine de la structure pour les zones et idem pour les longueurs. Il faut prévoir une méthode qui renvoie ces paramètres pour une instance donnée (n'oubliez alors pas dans le prototype l'opérateur &, voir e.g. la méthode "Donne" dans notre exemple précédent). Il faut aussi prévoir parmi les différents constructeurs possibles (en fonction des diverses initialisations) un constructeur minimal qui ne crée aucun tableau ni zone (il ne fait que mettre à 0 ou à NULL variables et pointeurs), cela permet de concevoir ensuite une méthode de recopie d'une instance initialisée vers une instance vide. C'est cette méthode qui se chargera de créer ces tableaux et zones en fonction de l'instance à dupliquer.
Je passe sous silence le problème de la dérivation des classes c'est-à-dire le fait qu'une classe puisse être créée sur la base d'une autre classe, héritant ainsi de toutes les caractéristiques de la classe dérivante. La dérivation en effet nous paraît un problème mineur par rapport au concept même de classe qui a suscité cet article. Sachez simplement que si votre classe est susceptible d'être dérivée par la suite, vous pouvez alors protéger vos membres de classes par le mot réservé "protected". C'est une sorte de moyen terme entre "private" et "public". Une donnée protégée reste accessible aux fonctions membres de la classe dérivée mais est inaccessible à l'utilisateur. Puisque c'est normalement le concepteur de la classe qui écrit les fonctions membres, cela revient à dire qu'un membre "protected" est "public" pour le concepteur de la classe mais "private" pour son utilisateur (alors que "private" signifie "définitivement private"). Notre but était simplement de montrer la façon d'utiliser les classes, savoir les instancier par new et les détruire par delete mais surtout savoir déclarer un tableau de pointeurs vers de tels objets et créer des tableaux d'informations parallèles tout en disposant dans les variables générales d'un matériel minimal d'accès à une classe active. C'est là que la notion de classe prend tout son sens, un tableau d'objets et l'accès à un objet actif à un moment donné.
Je conclus en disant qu'aussi aberrant que cela pourra sembler, une classe, ça n'existe pas. Cela peut paraître étrange à ce stade mais il en est ainsi. En effet, quand vous travaillez avec des classes, à aucun moment vous ne vous adressez au microprocesseur qui seul représente la réalité objective et physique, vous vous adressez uniquement au compilateur. C'est une façon de dire au compilateur "veillez à mes données, que les données encapsulées soient bien inaccessibles etc.". D'ailleurs, si vous traduisiez un programme C++ en assembleur, vous ne pourriez jamais poser le problème en termes de classes, ça n'aurait aucun sens et ça serait impossible. Une classe est donc une vue de l'esprit à l'intention du seul compilateur C++ qui vous aide ainsi à structurer vos données et vérifie notamment l'encapsulation. Vous avez ainsi la certitude (et c'est une assurance utile dans le travail en équipe) que les données privées ne sont jamais modifiées par l'utilisateur de la classe.
Je vous quitte par ce mot virgilien :
SAT PRATA BIBERUNT (troisième bucolique, 111).
Voyez aussi mes
Remarques de développement avec C++ Builder 5
ainsi que mon aide avec le
compilateur gratuit Borland C++.
|