Ce paragraphe traite des pointeurs, des problèmes liés à l'allocation dynamique de mémoire, et des moyens qui existent de résoudre ces problèmes... plutôt d'éviter leur apparition, car en ce domaine le préventif est bien plus aisé que le curatif...
Nous avons vu précédemment que la durée de vie d'une
variable s'étendait durant toute la portée de son nom.
La mémoire est allouée, et le
constructeur de l'objet est appelé en début de portée; le destructeur
est appelé et la mémoire est rendue au système à la fin de portée. Les
objets utilisés ainsi utilisent une partie de la mémoire vive appelée
la pile
. La structure de pile est en effet parfaitement
adaptée à la gestion des règles de portée. Or, il peut être
intéressant de stocker des données à des endroits de la mémoire qui ne
seront pas sujets soumis aux règles de portée; cela peut se faire
grâce à:
L'allocation-libération de mémoire étant à la charge
du programmeur, elle peut se faire dans n'importe quel ordre. La
structure de pile n'est alors plus adaptée, et de fait la mémoire est
allouée dans une autre zône de la mémoire, appelée le tas
(heap
).
Le programmeur
doit effectivement gérer la libération de mémoire... s'il ne
le fait pas, ou s'il le fait mal, les pires conséquences (à savoir un
plantage du programme) peuvent arriver.
La seule zône de mémoire que le programme peut adresser directement est la pile. Les pointeurs se trouveront donc quelque part dans la pile, au même titre que n'importe quelle variable. Les objets pointés, par contre, se trouveront dans le tas. Il doit y avoir en permanence un lien entre ces deux zônes de mémoire. Garder ce lien intact est la première préoccupation d'une bonne gestion de la mémoire.
Lorsqu'un objet est alloué dynamiquement, au moins un pointeur doit pointer sur lui: sinon, le lien évoqué ci-dessus est brisé, et l'objet est inutilisable. On peut dire qu'il est perdu, mais surtout la mémoire correspondante est perdue. Avant de briser le lien, il aurait fallu rendre la mémoire au système. Suivant les cas de figure, cela peut être grave ou pas. Par exemple, si l'allocation de mémoire a lieu dans une boucle, à chaque itération de la boucle on perd un peu de mémoire... d'oû l'expressoin fuite de mémoire. Si le nombre d'itérations est important, il y a un moment oû le système refusera de donner de la mémoire supplémentaire au programme, et celui-ci sera interrompu brutalement.
Il est parfaitement possible de faire pointer
plusieurs pointeurs vers le même objet. Mais dans ce cas si l'objet
est détruit (car le programmeur a consciencieusement rendu la mémoire
au système) les autres pointeurs pointeront sur une zône de mémoire
qui ne contient plus de données valides... soit elle contient
n'importe quoi ("du jargon" (garbage
)), soit elle
contient de nouvelles données, mais qui ne sont peut-être pas
structurées de la même manière que les précédentes. Le pointeur va
donc "pendouiller", et si on cherche à l'utiliser, il peut se passer
n'importe quoi, mais le pire est à craindre.
Compte tenu de ce qui précède, on voit donc qu'on peut définir deux sortes de pointeurs:
Bien sûr, la propriété d'un objet peut passer d'un pointeur à l'autre. D'autre part, il faut bien avoir présent à l'esprit que ces notions, importantes lors de la phase de conception, ne sont pas présentes dans le langage lui-même: le C++ ne comprend en effet aucune gestion de la mémoire, celle-ci restant à la charge du programmeur. Cependant, des objets (dont l'un d'entre eux se trouve déclaré dans la bibliothèque standard) vont pouvoir nous aider.
new
et delete
L'opérateur new
est utilisé pour allouer
de la mémoire pour un objet, delete
est utilisé pour
redonner la mémoire au système. Le (ou les) paramètres passés à
new
seront passés au constructeur de l'objet:
main() { const complexe J(0,1); complexe* C = new complexe(5,5); *C = J; delete C; };
new[]
et delete[]
Ils servent à allouer de la mémoire pour un
tableau d'objets. Ils ne peuvent etre utilisés qu'à la
condition qu'existe pour notre objet un constructeur par défaut, à qui
on puisse ne pas passer de paramètres. C'est le cas pour notre objet
complexe
, nous pouvons donc écrire:
main() { const complexe J(0,1); int taille=100; complexe* C = new complexe[taille]; for (int i=0; i<100; ++i) { C[i] = J; } delete[] C; };
Un tableau de 100 complexes est dynamiquement alloué.
Les complexes sont tous initialisés à 0 (constructeur par défaut),
puis affectés à la valeur J
. Enfin, le tableau est
détruit et la mémoire est rendue au système.
On ne peut allouer un
tableau d'objets de cette manière que si les objets en
question possèdent un constructeur par défaut. Il n'est pas possible
de passer des paramètres aux constructeurs des objets créés.
La taille peut parfaitement être une variable, comme on le voit dans cet exemple.
Nous avons en C
des fonctions
d'allocation dynamique de mémoire: malloc
et
free
pour allouer de la mémoire et la rendre au système,
realloc
pour refaire une allocation mémoire lorsque le
bloc précédemment alloué est trop juste. Tout cela est
réutilisable, à condition de prendre quelques précautions:
new
alloue la mémoire, puis appelle le
constructeur. malloc
n'appelle pas le
constructeur. Attention, donc à l'initialisation correcte
de l'objet.delete
appelle le destructeur puis rend la mémoire au système. free
n'appelle pas le
destructeur. Attention donc au code qui ne sera pas exécutérealloc
ne doit pas être utilisé dans le
cas d'objets de type class: realloc
va provoquer
une copie de la mémoire bit à bit, ce qui risque de provoquer
des catastrophes dans certains cas.new
pour créer un objet, utilisez delete
pour le
détruire. Si vous avez utilisé alloc
pour créer un
objet, utilisez free
pour le détruire.Conclusion: pour du code C++, il n'y a aucune raison
de ne pas utiliser les opérateurs du C++, new
et
delete
. Mais il faut savoir que le code C écrit avec
malloc
et free
(même realloc
dans le cas de zônes d'entiers ou de caractères, par exemple) reste
utilisable.
Le constructeur d'un objet est l'endroit rêvé pour
appeler new
. De même, le destructeur du même objet est
l'endroit rôvé pour appeler delete
.
Attention au constructeur de copie; à chaque copie, il faudra prendre une décision; il peut en effet se présenter plusieurs cas de figure:
Des trois solutions
ci-dessus, la première est très dangereuse: en effet, elle
risque fort d'aboutir à des objets "irresponsables" vis-à-vis de
l'allocation mémoire. Cette solution est toutefois acceptable lorsque
les objets référents comptent eux-mêmes les références
. La
seconde solution peut être implémentée par un
auto_ptr
.
auto_ptr
auto_ptr
fait partie de la bibliothèque standard du C++
. Il sert à définir un pointeur "intelligent"... en tous cas fort
sympathique, ayant les caractéristiques suivantes:
auto_ptr
est toujours propriétaire de
l'objet sur lequel il pointeauto_ptr
est détruit, l'objet sur lequel
il pointe est détruit également. On est donc assuré de ne pas
avoir de pointeur pendouillant.auto_ptr
, l'auto_ptr
original perd l'objet pointé, de sorte que s'il
est détruit, le référent ne sera pas détruit. Par contre, si
le nouvel auto_ptr
est détruit, le
référent sera détruit puisque la copie est le nouveau propriétaire.Le code suivant, qui utilise des objets de type
complexe
, illustre l'utilisation d'auto_ptr
:
typedef auto_ptr<complexe> complexe_ptr; void main() { complexe_ptr c2(new complexe); // allocation d'un auto_ptr. { complexe_ptr c1(new complexe(5,5)); // allocation d'un auto_ptr c2=c1; // c2 devient proprietaire du complexe // son ancien referent est detruit // c1 n'est plus le propriétaire, }; // il peut être détruit. }
Un objet de type auto_ptr
peut donc
avantageusement être alloué dans le constructeur à la place d'un objet
ordinaire. La destruction du référent sera automatique au moment de
la destruction de l'objet, sans même qu'il soit nécessaire de le
spécifier dans le destructeur. Dans le code ci-dessus, tous nos ennuis
viennent en effet uniquement de c3
, déclaré comme
complexe*
.
En fait, l'important n'est pas là: après tout, vous êtes bien assez
malin pour ne pas oublier d'écrire les quelques lignes de code du
destructeur correspondant aux instructions
delete
. L'important, c'est qu'il est fort possible que le
constructeur, après avoir alloué un ou plusieurs pointeurs,
génère une exception (fonctionnement normal pour un constructeur). La
construction de l'objet est alors interrompue, et le destructeur
n'est pas appelé. Résultat: une fuite de mémoire.
L'auto_ptr
est donc la solution élégante, car lorsque
l'auto_ptr sera détruit, le référent sera lui aussi détruit.
Pour éviter les ennuis évoqués ci-dessus, arrangez-vous pour qu'un objet ne gère qu'une seule ressource. Eventuellement, si vous devez gérer trois ressources, rien ne vous empêche d'utiliser trois objets différents, quitte à les insérer dans un autre objet.
Les trois principes fondamentaux pour gérer les ressources sont:
auto_ptr
est un bon exemple d'objet dont l'unique raison
d'être est la gestion d'une ressource (en l'occurrence la mémoire).
On l'a vu, les exceptions peuvent avoir pour conséquence l'apparition de fuites de mémoire ou de pointeurs pendouillants. Voici quelques astuces permettant d'éviter ces désagrément.
Utiliser auto_ptr
le plus souvent possible.
Après avoir exécuté delete p
, on se
retrouve avec un pointeur pendouillant. Donc, remettez les choses
en ordre dès la ligne suivante (à moins, bien sûr, qu'on ne
sorte de la portée). Par exemple avec p=NULL;
En effet,
s'il arrive qu'un second appel delete
soit lancé (par
exemple à partir du destructeur), il ne se passera rien si
p
vaut NULL
, alors que le résultat sera
catastrophique si p
pendouille.
Le code suivant est dangereux:
delete p ; p = new toto();
Si
toto
envoie une exception, p
continue à
pendouiller. Il vaut mieux faire:
delete p ; p = NULL; p = new toto();
Il est possible d'implémenter des objets qui
comptent le nombre de pointeurs mis sur eux. De cette
manière, il est aisé, lorsque ce nombre arrive à 0, de faire en sorte
que ces objets s'auto-détruisent. Cela passe, bien sûr, par la
surcharge des opérateurs =, *, ->
.