| É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>
using namespace std;
// 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()
{
free(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)--------------
int main()
{
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>
using namespace std;
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";
delete 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;
}
//--------------------------------------
int main()
{
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,
voir mon article sur les indirections).
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 :
int main()
{
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 :
int main()
{
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.
int main()
{
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;
int main()
{
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>
using namespace std;
// 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;
int main()
{
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.
int main()
{
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>
using namespace std;
// 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>
using namespace std;
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.
int main()
{
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++.
|