Signification des indirections du C++

Gilles Louïse

- Titre : Signification des indirections du C++

- Auteur : Gilles Louïse

- Parution : 14 avril 2002

- Licence : Copyright ® 2013 Gilles Louïse. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- Notion d'indirection

- Exemple 1 : instanciation avec malloc/free

- Exemple 2 : instanciation avec new/delete

- Exemple 2 bis : l'entier existe déjà

- Exemple 3 : une seule dimension avec malloc

- Exemple 4 : une seule dimension, new et indice[]

- Exemple 5 : une seule dimension avec accès par *pEnt++

- Exemple 6 : deux dimensions

- Exemple 7 : trois dimensions

- Exemple 8 : six dimensions

- Exemple 9 : un seul entier via ****int

- Exemple 10 : une seule classe instanciée en pile

- Exemple 11 : plusieurs classes instanciées par un seul pointeur

- Exemple 12 : une seule classe (étoile unique)

- Exemple 13 : plusieurs classes (deux étoiles)

- Exemple 14 : plusieurs classes (deux étoiles)

- En résumé

Notion d'indirection

On appelle indirection l'accès indirect à un objet via un pointeur, le pointeur ayant été déclaré par l'opérateur * (étoile). Quand vous écrivez int i, vous instanciez un integer appelé i et vous y accédez directement, par exemple i=5. Dans ces conditions, i représente l'entier (et &i l'adresse où il est stocké en mémoire). Ce type d'instanciation directe se fait toujours en pile (sauf pour les variables générales d'un programme qui sont dans un segment particulier de données non initialisées), l'instanciation dans le tas se faisant par new ou malloc. Mais quand vous écrivez int *i, vous instanciez non plus un entier, mais un pointeur sur un entier, i est un pointeur sur un entier (ce pointeur est en pile, il se trouve à l'adresse &i). Cela dit, l'entier en lui-même n'existe pas encore, seul le pointeur (sur un entier inexistant) existe, bien qu'il ne soit pas encore initialisé en sorte qu'il ne pointe pour l'instant nulle part, il faudra créer cet entier dans le tas par new ou malloc et par là même donner au pointeur l'adresse de l'entier maintenant existant. On écrira donc i=new int ou encore i=(int*)malloc(sizeof(int)). Par ces syntaxes, deux choses vont se produire, d'une part l'entier va être créé (instancié) dans le tas et d'autre part le pointeur i en pile pointera cet entier dans le tas. Maintenant on accède indirectement à cet entier via la syntaxe *i, par exemple *i=5. Il faudra ensuite ne pas oublier de le détruire par delete i ou free(i) suivant respectivement qu'il a été créé par new ou malloc.

Exemple 1 : instanciation avec malloc/free

#include <iostream>
using namespace std;
int main()
{
int *i;
i=(int*) malloc(sizeof(int));
*i=5;
cout<<*i;
free(i);
}

Exemple 2 : instanciation avec new/delete

#include <iostream>
using namespace std;
int main()
{
int *i;
i=new int;
*i=5;
cout<<*i;
delete i;
}

Remarque 1 : ces petits programmes ont été testés avec le compilateur gratuit de Borland, voir à ce sujet mon article pour démarrer avec ce compilateur et les premiers pas d'utilisation sur mon site. Avec un autre compilateur, l'exemple 1 nécessiterait peut-être d'ajouter #include <malloc.h>, mais cette précision n'est pas nécessaire avec le compilateur de Borland.

Remarque 2 : dans une première version de cet article, j'avais déclaré le programme principal par "void main". Un internaute m'a fait savoir que cette écriture n'est pas standard, bien que tolérée par les compilateurs e.g. celui de Borland. À ce sujet, je vous renvoie à l'excellente FAQ par le créateur du C++ pour d'autres petites finesses de ce genre.

Remarque 3 : le texte précédent dit : il faudra créer cet entier dans le tas par new ou malloc. C'est une simplification dans le cadre de cet article, lequel est destiné à mieux vous faire comprendre les indirections. Mais dans la réalité, vous n'êtes évidemment tenu à rien. En principe, si vous déclarez un pointeur, vous allez le plus souvent créer un objet (ici un entier) lié à ce pointeur, mais cela n'est pas du tout une obligation, car ce pointeur peut se relier à un entier déjà existant via l'opérateur &. Par exemple

Exemple 2 bis : l'entier existe déjà

#include <iostream>
using namespace std;
int main()
{
int i=4;
int* pEnt;
pEnt=&i;
cout<<*pEnt;
}

Ici l'entier i existe déjà, il est égal à 4, on relie le pointeur pEnt (pointeur sur un entier) à i en lui donnant l'adresse via l'opérateur & (pEnt=&i;). Cette instruction signifie qu'on donne à pEnt l'adresse de i, en conséquence de quoi *pEnt est égal à l'entier i lui-même. Il n'y a alors plus de différence entre i et *pEnt si ce n'est que dans le cas de i, on accède directement à l'entier alors que dans le cas de *Pent, on accède à l'entier via un pointeur. Mais dans les deux cas, on a ici i=*pEnt=4.

La signification première de l'opérateur * (étoile) est donc l'indirection, on n'écrit plus i=5 (accès direct), mais *i=5 (accès indirect). Mais l'indirection offre une possibilité supplémentaire à savoir la dimension c'est-à-dire la multiplicité. Ceci se comprend intuitivement. Personne en voyant la déclaration char* P n'affirmera que le pointeur P ne pointe qu'un seul caractère ou qu'un seul octet (bien que ce soit possible, qui peut le plus peut le moins), mais bien une multiplicité de caractères (dite chaîne de caractères ou suite d'octets). On peut d'ailleurs adresser individuellement ces caractères ou octets par la syntaxe P[i] ou encore *(P+i) qui signifie la même chose, cette écriture adressant le caractère d'indice i de la chaîne ou suite d'octets.

Dans les deux exemples précédents, nous n'avions instancié qu'un seul entier pour le pointeur i. Après avoir déclaré le pointeur int *i, nous avons créé un integer dans le tas par i=new int. Dans ces conditions, l'entier était accessible par *i, syntaxe qui équivaut à i[0], car puisque i[n] équivaut à *(i+n), i[0] équivaut à *(i+0) soit *i. Or, rien ne nous oblige à ne déclarer qu'un seul entier pour le pointeur *i, nous aurions pu écrire i=new int[10] créant ainsi 10 entiers indicés de 0 à 9, le premier sera i[0] ou *(i+0) c'est-à-dire *i, et le dernier i[9] ou *(i+9). Dans ces conditions, la libération de la mémoire ne se fera plus par delete mais par delete[]. À new doit correspondre delete, à new[] doit correspondre delete[].

On peut aussi adresser tous les entiers par la syntaxe *i, mais le pointeur i devra se mouvoir par i++ ou i--(ou encore ++i et --i en préincrémentation et prédécrémentation). En général, comme on lit séquentiellement la mémoire, on lit le nombre pointé tout en avançant le pointeur et l'on écrit plus volontiers *i++. De même, au lieu de i=(int*) malloc(sizeof(int)), nous aurions pu écrire i=(int*) malloc(sizeof(int)*10) pour créer dix entiers via malloc. Dans ces conditions, la mémoire est libérée par free(i).

Exemple 3 : une seule dimension avec malloc

#include <iostream>
using namespace std;
//------------------------------
const int NbEnt=50;
//------------------------------
int main()
{
int *pEnt,i;

/* creation par malloc de NbEnt entiers */
pEnt=(int*)malloc(NbEnt*sizeof(int));

/* accès aux NbEnt entiers via une syntaxe du type pEnt[i] */
for(i=0;i<NbEnt;i++) pEnt[i]=i*2;

/* affichage de vérification */
for(i=0;i<NbEnt;i++) cout<<pEnt[i]<<" ";

/* destruction des NbEnt entiers */
free(pEnt);
}

Exemple 4 : une seule dimension, new et indice[]

#include <iostream>
using namespace std;
//------------------------------
const int NbEnt=50;
//------------------------------
int main()
{
int *pEnt,i;

/* création par new[] de NbEnt entiers */
pEnt=new int[NbEnt];

/* accès aux NbEnt entiers via une syntaxe du type pEnt[i] */
for(i=0;i<NbEnt;i++) pEnt[i]=i*2;

/* affichage de vérification */
for(i=0;i<NbEnt;i++) cout<<pEnt[i]<<" ";

/* destruction des NbEnt entiers par delete[] */
delete[]pEnt;
}

Exemple 5 : une seule dimension avec accès par *pEnt++

#include <iostream>
using namespace std;
//------------------------------
const int NbEnt=50;
//------------------------------
int main()
{
int *pEnt, *pEntMem, i;

/* creation par malloc de NbEnt entiers */
pEnt=(int*)malloc(NbEnt*sizeof(int));

/* comme dans cet exemple pEnt va bouger, on mémorise
son adresse dans pEntMem une fois pour toutes */
pEntMem=pEnt;

/* accès aux NbEnt entiers via une syntaxe du type *pEnt */
for(i=0;i<NbEnt;i++) *pEnt++=i*2;

/* affichage de vérification */
pEnt=pEntMem;
for(i=0;i<NbEnt;i++) cout<<*pEnt++<<" ";

/* destruction des NbEnt entiers avec adresse d'origine intouchée */
free(pEntMem);
}

Exemple 6 : deux dimensions

#include <iostream>
using namespace std;
const int Dim1=6, Dim2=4;
//------------------------------
int main()
{
int **pEnt, i, j;

/* on crée d'abord Dim1 int* (Dim1 pointeurs d'entiers) */
pEnt= new int*[Dim1];

/* pour chacun des Dim1 int*, on crée Dim2 int */
for(i=0;i<Dim1;i++) pEnt[i]=new int[Dim2];

/* accès au tableau d'entiers par la syntaxe pEnt[i][j] */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
pEnt[i][j]=i*j;

/* affichage de vérification */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
cout<<pEnt[i][j]<<" ";

/* destruction du tableau par delete[] */
for(i=0;i<Dim1;i++) delete[]pEnt[i];
delete[]pEnt;
}

Remarquez que le tableau à deux dimensions s'est créé en deux fois, d'une part une série de Dim1 pointeurs d'entiers (pEnt = new int*[Dim1]) et d'autre part une série de Dim2 entiers pour chacun de ces Dim1 pointeurs d'entiers (for (int i=0;i<Dim1;i++) pEnt[i]=new int[Dim2]). Chaque entier est alors accessible par une syntaxe de type pEnt[i][j]. Pour restituer la mémoire allouée au système, pour chacun des Dim1 pointeurs d'entiers on restitue les Dim2 entiers (delete[]pEnt[i]) puis on restitue les dim1 int* par delete[]pEnt. La destruction se fait logiquement dans l'ordre inverse de la construction.

Notez que le compilateur n'indiquera pas d'erreur si vous oubliez les crochets dans la syntaxe delete[]pEnt. Mais c'est la seule qui soit correcte. Si vous instanciez par new, il faut détruire par delete, si vous instanciez par new[], il faut détruire par delete[]. De même, si vous créez une zone par malloc/realloc, il faut la libérer par free (et non par delete bien que le compilateur n'indique aucune erreur). La raison de ce laxisme vient sans doute de ce qu'une variable ne porte pas en elle-même la façon dont elle est utilisée. Ainsi, imaginons une fonction toto(char *P), le compilateur n'est pas censé savoir la façon dont P a été initialisé donc, s'il faut libérer la mémoire pointée par P, on peut aussi bien avoir free(P) que delete P que delete[]P, cela dépend de la façon dont P a été utilisé pour pointer la mémoire.

Dans l'absolu, il faudrait toujours utiliser le couple new/delete du C++ et bannir définitivement l'obsolète malloc/free du C. Mais c'est plus facile à dire qu'à faire. En effet, malloc a un avantage appréciable pour le développeur, il permet un realloc, la zone pointée peut donc notamment être agrandie si nécessaire alors qu'une création par new n'offre pas cette possibilité, l'objet créé par new est de longueur fixe. Or, comme dans la pratique, le développeur ne connaît pas à l'avance la longueur des zones mémoire dont il aura besoin, il alloue une première fois par malloc puis réalloue autant de fois que nécessaire par realloc. Ou encore il initialise à NULL son pointeur et n'utilise que le realloc, ces deux possibilités sont identiques, car si le pointeur invoqué pointe NULL, un realloc équivaut à un malloc (cette technique permet de ne pas faire de cas particulier pour la première allocation, on utilise toujours realloc sans distinguer la première allocation des autres). Pour contourner cette difficulté, il faudrait savoir utiliser la STL (Standard Template Library). Mais c'est beaucoup plus difficile.

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 Remarquesalinéa 47 de mes "Remarques") ou encore les TList () qui résolvent la question et gèrent parfaitement la mémoire à la mode C++, ces objets ayant été très probablement conçus sur la base de la STL. 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.

Exemple 7 : trois dimensions

#include <iostream>
using namespace std;
const int Dim1=6, Dim2=4, Dim3=5;
//------------------------------
int main()
{
int ***pEnt, i, j, k;

/* on crée d'abord Dim1 int** */
pEnt= new int**[Dim1];

/* pour chacun des int**, on crée Dim2 int* */
for(i=0;i<Dim1;i++) pEnt[i]=new int*[Dim2];

/* pour chacun des int*, on crée Dim3 int */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
pEnt[i][j]=new int[Dim3];

/* accès au tableau d'entiers via pEnt[i][j][k] */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
for(k=0;k<Dim3;k++)
pEnt[i][j][k]=i*j*k;

/* affichage de vérification */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
for(k=0;k<Dim3;k++)
cout<<pEnt[i][j][k]<<" ";

/* destruction du tableau */
for(i=0;i<Dim1;i++)
{
for(j=0;j<Dim2;j++) delete[]pEnt[i][j];
delete[]pEnt[i];
}
delete[]pEnt;
}

On crée d'abord les Dim1 int** puis pour chacun des Dim1 int** les Dim2 int* puis pour chacun des Dim2 int* les Dim3 int. Dans ces conditions, on accède logiquement aux entiers par la syntaxe pEnt[i][j][k], logiquement car puisque pEnt est déclaré avec trois étoiles (int***), c'est donc un tableau à trois dimensions. On comprend qu'on peut facilement généraliser à n dimensions, on créerait les pointeurs avec n-1 étoiles, pour chacun d'eux les pointeurs avec n-2 étoiles et ainsi de suite jusqu'à créer les éléments à zéro étoile c'est-à-dire l'élément pur (ici int, un nombre entier). La restitution ou destruction des éléments se fait dans l'ordre inverse. Ici (en trois dimensions), pour chacun des dim2 int* on détruit les Dim3 int et les Dim2 int*, enfin on détruit les Dim1 int**. Dans la pratique, il est probablement rare d'avoir à créer des tableaux de dimension supérieure à trois, mais nous proposons un exemple à six dimensions, ce qui validera le principe de création, d'accès et de destruction.

Exemple 8 : six dimensions

#include <iostream>
using namespace std;
//------------------------------
const int Dim1=6, Dim2=4, Dim3=5, Dim4=2, Dim5=2, Dim6=2;
//------------------------------
int main()
{
int ******pEnt, i, j, k, l, m, n;

/* on crée d'abord Dim1 int***** */
pEnt= new int*****[Dim1];

/* pour chacun des int*****, on crée Dim2 int**** */
for(i=0;i<Dim1;i++) pEnt[i]=new int****[Dim2];

/* pour chacun des int****, on crée Dim3 int*** */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
pEnt[i][j]=new int***[Dim3];

/* pour chacun des int***, on crée Dim4 int** */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
for(k=0;k<Dim3;k++)
pEnt[i][j][k]=new int**[Dim4];

/* pour chacun des int**, on crée Dim5 int* */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
for(k=0;k<Dim3;k++)
for(l=0;l<Dim4;l++)
pEnt[i][j][k][l]=new int*[Dim5];

/* pour chacun des int*, on crée Dim6 int */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
for(k=0;k<Dim3;k++)
for(l=0;l<Dim4;l++)
for(m=0;m<Dim5;m++)
pEnt[i][j][k][l][m]=new int[Dim6];

/* accès au tableau d'entiers via pEnt[i][j][k][l][m][n] */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
for(k=0;k<Dim3;k++)
for(l=0;l<Dim4;l++)
for(m=0;m<Dim5;m++)
for(n=0;n<Dim6;n++)
pEnt[i][j][k][l][m][n]=(i+1)*(j+1)*(k+1)*(l+1)*(m+1)*(n+1);

/* affichage de vérification */
for(i=0;i<Dim1;i++)
for(j=0;j<Dim2;j++)
for(k=0;k<Dim3;k++)
for(l=0;l<Dim4;l++)
for(m=0;m<Dim5;m++)
for(n=0;n<Dim6;n++)
cout<<pEnt[i][j][k][l][m][n]<<" ";

/* destruction du tableau d'entiers */
for(i=0;i<Dim1;i++)
{
for(j=0;j<Dim2;j++)
{
for(k=0;k<Dim3;k++)
{
for(l=0;l<Dim4;l++)
{
for(m=0;m<Dim5;m++) delete pEnt[i][j][k][l][m];
delete[]pEnt[i][j][k][l];
}
delete[]pEnt[i][j][k];
}
delete[]pEnt[i][j];
}
delete[]pEnt[i];
}

delete[]pEnt;
}

Tout cela montre que le nombre d'étoiles dans la déclaration n'a pas à nous inquiéter, il correspond normalement au nombre de dimensions. Pas d'étoile, pas de dimension donc un seul élément (un point). Une étoile, une dimension donc une multiplicité (une ligne). Deux étoiles, deux dimensions donc une multiplicité de multiplicité (une surface). Trois étoiles, trois dimensions donc une multiplicité de multiplicité de multiplicité (un volume) et ainsi de suite.

Bien entendu ce n'est pas une règle absolue puisque l'opérateur * a double signification, il exprime toujours au moins une indirection et peut en plus exprimer une dimension sans que ce soit obligatoire. On peut donc imaginer un fou furieux qui déclare int****i pour n'utiliser in fine qu'un seul entier accessible via une quadruple indirection.

Exemple 9 : un seul entier via ****int

#include <iostream>
using namespace std;
int main()
{
int ****pEnt;

/* pEnt pointe un int*** accessible via *pEnt */
pEnt= new int***;

/* *pEnt pointe un int** accessible via **pEnt */
*pEnt=new int**;

/* **pEnt pointe un int** accessible via ***pEnt */
**pEnt=new int*;

/* ***pEnt pointe un int accessible via ****pEnt */
***pEnt=new int;

/* on accède donc à l'entier par ****pEnt */
****pEnt=5;

/* affichage de vérification */
cout<<****pEnt<<endl;

/* ou encore par pEnt[0][0][0][0] */
pEnt[0][0][0][0]=6;

/* affichage de vérification */
cout<<pEnt[0][0][0][0];

/* on détruit l'int par son pointeur ***pEnt */
delete ***pEnt;

/* on détruit l'int* par son pointeur **pEnt */
delete **pEnt;

/* on détruit l'int** par son pointeur *pEnt */
delete *pEnt;

/* on détruit l'int*** par son pointeur pEnt */
delete pEnt;
}

On ne détruit pas l'int****, car ce pointeur à quadruple indirection est en pile, c'est le compilateur qui s'en charge puisqu'il gère la pile (comme si d'ailleurs nous avions écrit simplement int i pour utiliser un integer). Dans ces conditions, ****pEnt est un entier (int), ***pEnt est un pointeur sur un entier (int*), **pEnt est un pointeur sur un pointeur sur un entier (int**), *pEnt est un pointeur sur un pointeur sur un pointeur sur un entier (int***) et pEnt est un pointeur sur un pointeur sur un pointeur sur un pointeur sur un entier (int****). L'exemple précédent montre que ****pEnt équivaut à pEnt[0][0][0][0], on pourrait aussi accéder à cet entier via ***pEnt[0] ou **pEnt[0][0] ou encore *pEnt[0][0][0], l'important étant que le nombre d'étoiles et/ou de zéros entre crochets soit égal à quatre (puisqu'il y a quatre indirections).

Remarque : s'il est vrai que ****pEnt équivaut à pEnt[0][0][0][0], il convient de préciser que le mode de calcul ne sera pas tout à fait le même, le code en assembleur nous révèle des différences minimes bien que le nombre d'instructions soit le même dans les deux cas. Avec C++ Builder, vous pouvez consulter l'assembleur des instructions, on fait F4 pour forcer l'arrêt à l'instruction C++ où se trouve le curseur et une fois arrêté, on fait Ctrl+Alt+C qui montre la CPU. Voir mes articles sur l'assembleur pour plus de détails sur mon site.

Code de ****pEnt=5;
mov eax,dword ptr [ebp-12]
mov eax,dword ptr [eax]
mov edx,dword ptr [eax]
mov ecx,dword ptr [edx]
mov dword ptr [ecx],5

Codage de pEnt[0][0][0][0]=6;
mov eax,dword ptr [ebp-12]
mov edx,dword ptr [eax]
mov ecx,dword ptr [edx]
mov eax,dword ptr [ecx]
mov dword ptr [eax],6

Cela dit, pour un objet de type classe (ou structure), qu'il soit standard dans une palette de composants (e.g. C++ Builder) ou créé par le développeur, l'approche nous semble différente pour deux raisons. La première est que s'agissant des classes, on préfère en général travailler avec des pointeurs, par exemple C++ Builder déclare la forme principale par TForm1* Form1, Form1 étant un pointeur sur une classe de type TForm1. La deuxième est qu'en règle générale on travaille soit avec une seule classe (pas de dimension) soit avec plusieurs (une dimension), mais on n'imagine moins (bien que tout soit possible) un tableau de classes à deux dimensions, encore moins à trois. Dans ces conditions, s'agissant des classes, le nombre de dimensions s'obtient en soustrayant une étoile à la déclaration qui doit normalement en contenir au moins une. Il n'y aura donc que deux cas, le cas d'un seul objet instancié (une seule étoile, c'est un pointeur sur cet objet) et le cas d'une multiplicité d'objets instanciés (deux étoiles, donc un pointeur sur une multiplicité de pointeurs vers ce type objet). On perd donc une dimension dans la déclaration des classes par rapport aux éléments classiques du C++ et ce, parce qu'il faut de préférence que ce soit un pointeur qui pointe une classe, ce pointeur étant la dimension perdue.

Soit donc C une classe Chose. Si nous instancions cette classe par la syntaxe

Chose C;

(c'est-à-dire sans étoile donc sans indirection) la classe serait instanciée en pile, ce qui ne se fait pas.

Exemple 10 : une seule classe instanciée en pile

#include <iostream>
using namespace std;
class Chose
{
public:
char C[1000];
int i;
Chose(void);
~Chose(void);
};
/* Constructeur */
Chose::Chose(void)
{
cout<<"Construction"<<endl;
i=7;
}
/* Destructeur */
Chose::~Chose(void)
{
cout<<"Destruction"<<endl;
}
//------------------------------
int main()
{
Chose C;
cout<<C.i<<endl;
}

Nous faisons afficher un message au moment de la construction et de la destruction, cela prouve que le constructeur et le destructeur sont bien appelés. Remarquez que dans ce cas, on accède aux éléments de la classe non plus par la flèche, mais par le point, la flèche renvoie au tas et le point à la pile (cette règle n'est pas parfaite, c'est simplement un moyen mnémotechnique le plus souvent vérifié, dans l'absolu il faudrait dire que la flèche s'applique à un pointeur et le point à une référence). Dans ces conditions, on n'utilise plus new ni delete, cette méthode est à éviter, une classe doit être instanciée par new et détruite par delete. On préfère donc instancier en pile un pointeur de classe (Chose *C) et instancier la classe en tant que telle dans le tas par new.

Imaginons maintenant que nous ayons déclaré un pointeur de classe

Chose* C;

nous pourrions certes créer plusieurs classes sur la base de ce seul pointeur en écrivant :

C=New Chose[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. Si toutefois vous utilisez cette méthode pour instancier vos classes, il faut alors les détruire par delete[]C puisqu'à new[] doit correspondre delete[].

Exemple 11 : plusieurs classes instanciées par un seul pointeur

#include <iostream>
using namespace std;
const NbCl=4;
class Chose
{
public:
char C[1000];
int i;
Chose(void);
~Chose(void);
};
/* Constructeur */
Chose::Chose(void)
{
cout<<"Construction"<<endl;
i=7;
}
/* Destructeur */
Chose::~Chose(void)
{
cout<<"Destruction"<<endl;
}
//------------------------------
int main()
{
Chose* C;
C=new Chose[NbCl];
for(int i=0;i<NbCl;i++) C[i].i=i*2;
for(int i=0;i<NbCl;i++) cout<<C[i].i<<endl;
delete[]C;
}

En instanciant ainsi vos classes, vous n'avez pas le choix du constructeur, c'est pourquoi ne nous la conseillons pas. D'ailleurs pour des classes complexes, il peu probable de pouvoir se contenter d'un constructeur par défaut.

Nous déconseillons donc les exemples 9 et 10. Notre règle étant d'avoir un pointeur par classe, il n'y aura que deux possibilités de déclaration. Première possibilité, un seule classe, une seule étoile :

Chose *C;

La classe sera alors instanciée par :

C = new Chose();

ou autre syntaxe, cela dépend du constructeur appelé, ici le constructeur par défaut, mais vous avez le choix du constructeur et vous pouvez avoir autant de constructeurs que vous voulez. Dans ces conditions, tous les éléments publics de la classe seront accessibles par une syntaxe du type :

C->

Et cet objet unique sera détruit par :

delete C;

Exemple 12 : une seule classe (étoile unique)

#include <iostream>
using namespace std;
class Chose
{
public:
char C[1000];
int i;
Chose(void);
~Chose(void);
};
/* Constructeur */
Chose::Chose(void)
{
cout<<"Construction"<<endl;
i=7;
}
/* Destructeur */
Chose::~Chose(void)
{
cout<<"Destruction"<<endl;
}
//------------------------------
int main()
{
Chose* C;
C=new Chose;
cout<<C->i<<endl;
delete C;
}

Deuxième possibilité : pour une multiplicité d'objets, il y aura deux étoiles :

Chose **C;

On instanciera d'abord NbCl (nombre de classes, constante ou nombre arbitraire) pointeurs de classes :

C=new Chose*[NbCl];

puis pour chacun de ces pointeurs, on créera la classe :

for(i=0;i<NbCl;i++) C[i]=new Chose;

Après utilisation, on détruira les NbCl classes :

for(i=0;i<NbCl;i++) delete C[i];

puis les NbCl pointeurs de classes :

delete[]C;

Exemple 13 : plusieurs classes (deux étoiles)

#include <iostream>
using namespace std;
const int NbCl=5;
//------------------------
class Chose
{
public:
char C[1000];
int i;
Chose(int);
~Chose(void);
};
/* Construction */
Chose::Chose(int N)
{
cout<<"Construction"<<endl;
i=N;
}
/* Destruction */
Chose::~Chose(void)
{
cout<<"Destruction"<<endl;
}
//------------------------------
int main()
{
int i;
Chose** C;
C=new Chose*[NbCl];
for(i=0;i<NbCl;i++) C[i]=new Chose(i);
for(i=0;i<NbCl;i++) cout<<C[i]->i<<" "<<endl;
for(i=0;i<NbCl;i++) delete C[i];
delete[]C;
}

Nous avons appelé ici non pas le constructeur par défaut (qui n'existe pas dans cet exemple), mais un constructeur ayant un entier pour argument.

Une autre raison pour laquelle nous n'aimons pas instancier NbCl classes par un seul pointeur comme suit

Chose* C;
C=new Chose[NbCl];

est que le nombre de classes instanciées ne peut plus changer, new renvoyant toujours à un nombre fixe d'éléments, alors que si vous passez par des pointeurs, vous avez la possibilité d'allouer les premiers pointeurs par malloc (ou realloc si le pointeur est initialisé à NULL), mais ce nombre peut changer dans la suite de l'exécution. Dans l'exemple suivant nous allouons NbCl1 pointeurs de classes puis NbCl2, le tout en passant par realloc.

Exemple 14 : plusieurs classes (deux étoiles)

#include <iostream>
using namespace std;
const int NbCl1=3, NbCl2=8;
//------------------------
class Chose
{
public:
char C[1000];
int i;
Chose(int);
~Chose(void);
};
/* Construction */
Chose::Chose(int N)
{
cout<<"Construction"<<endl;
i=N;
}
/* Destruction /
Chose::~Chose(void)
{
cout<<"Destruction"<<endl;
}
//------------------------------
int main()
{
int i;

/* C à NULL ce qui permettra d'utiliser realloc dès
la première allocation */
Chose** C=NULL;

/* NbCl1 pointeurs dans un premier temps */
C=(Chose**) realloc(C,sizeof(Chose*)*NbCl1);

/* Création des NbCl1 classes */
for(i=0;i<NbCl1;i++) C[i]=new Chose(i);

/* Affichage de vérification */
for(i=0;i<NbCl1;i++) cout<<C[i]->i<<" "<<endl;

/* on agrandit la zone jusqu'à NbCl2 pointeurs de classes */
C=(Chose**) realloc(C,sizeof(Chose*)*NbCl2);

/* on crée ces classes supplémentaires */
for(i=NbCl1;i<NbCl2;i++) C[i]=new Chose(i);

/* Affichage de vérification */
for(i=NbCl1;i<NbCl2;i++) cout<<C[i]->i<<" "<<endl;

/* Destruction des NbCl2 classes */
for(i=0;i<NbCl2;i++) delete C[i];

/* et libération de la zone de NbCl2 pointeurs */
free(C);
}

Bien entendu, si vous travaillez avec C++ Builder, il vous suffit d'utiliser un objet de type TList pour éviter malloc/realloc/free à éviter au maximum en C++.

À noter qu'il est très fréquent qu'une classe contienne en elle-même un tableau de pointeurs de classes (deux étoiles), chacune d'elles contenant à son tour un tableau de pointeurs de classes et ainsi de suite. Imaginons un logiciel musical. Celui-ci va traiter des partitions. On va donc créer une classe Partition et l'on va créer un tableau de pointeurs vers un objet de type Partition :

Partition **Ptt;

Ainsi, après instanciation dans le tas d'un certain nombre de pointeurs de partition et instanciation d'un certain nombre de partitions elles-mêmes, Ptt[i] représentera la partition numéro i. Maintenant, une partition est constituée d'un certain nombre de portées, on va donc créer la classe Portée et déclarer dans la classe Partition un tableau de pointeurs de portées :

Portee **Por;

Après création des pointeurs et instanciation d'un certain nombre d'objets de type Portée, Ptt[i]à Por[j] représentera la portée numéro j de la partition numero i. Maintenant, une portée est constituée de mesures, donc on va créer la classe Mesure et déclarer à l'intérieur de la classe Portée un tableau de pointeurs de mesures :

Mesure **Mes;

Après création des pointeurs et des objets de type Mesure, Ptt[i]à Por[j]à Mes[k] représentera la mesure numéro k de la portée numéro j de la partition numéro i. Maintenant, une mesure se constitue d'un certain nombre de voix (par exemple quatre noires correspondent à deux blanches et à une ronde (et bien d'autres), je peux donc fort bien écrire dans la même mesure quatre noires sur une voix, deux blanches sur une autre et une ronde sur une troisième), donc on crée la classe Voix et on déclare dans la classe Mesure un tableau de pointeurs de voix :

Voix **Vox;

Après création des pointeurs et des objets de type Voix, Ptt[i]à Por[j]à Mes[k]à Vox[m] représentera la voix numéro m de la mesure numéro k de la portée numéro j de la partition numéro i. Maintenant, une voix se constitue d'un certain nombre d'accords (même un silence n'est jamais qu'un accord de zéro note), donc on crée la classe Accord et on déclare dans la classe Voix un tableau de pointeurs d'accords :

Accord **Acc;

Après création des pointeurs et des objets de type Accord, Ptt[i]à Por[j]à Mes[k]à Vox[m]à Acc[n] représentera l'accord numéro n de la voix numéro m de la mesure numéro k de la portée numéro j de la partition numéro i.

Observez que dans ces conditions de structure, seul le pointeur vers des pointeurs de type Partition à savoir Ptt déclaré dans les variables générales d'un tel programme (Partition **Ptt) se trouve dans la zone spéciale des variables non initialisées soit quatre octets en tout et pour tout. Tout le reste se trouvera dans le tas, notamment le tableau de pointeurs de partitions, a fortiori les classes de type Partition instanciées et toutes les autres classes. Toute la construction repose sur le seul pointeur de partitions Ptt situé dans cette zone particulière.

En résumé

Le nombre d'étoiles dans la déclaration d'éléments standard du C++ (char, int, double, etc.) correspond normalement au nombre de dimensions du futur tableau. Mais pour les classes, étant donné que nous proposons de toujours passer par des pointeurs, nous perdons une dimension. Dans ces conditions, il y aura dans la déclaration une étoile pour un objet unique (un pointeur vers cet objet) et deux étoiles pour une multiplicité d'objets (un pointeur vers une multiplicité de pointeurs vers cet objet). Si donc Chose est une classe, il n'y aura que deux possibilités de déclaration, Chose *C pour un seul objet et Chose **C pour plusieurs.