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
Dans ce chapitre nous abordons les objets gestionnaires de ressources
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, les données peuvent être stocké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... Contrairement à d'autres langages,
le C++ ne propose pas de "ramasse-miette": la libération des ressources est de la respnsabilité du développeur, qui peut
ainsi choisir le moment où la ressource doit être libérée.
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.
Rien n'empêche 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, y compris un plantage du programme.
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. Ces notions ne sont pas présentes dans le langage lui-même. Cependant, plusieurs objets de la bibliothèque standard vont 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 bien faire la différence entre les deux utilisations:
new
alloue la mémoire, puis appelle le
constructeur. malloc
ne sait pas ce qu'est un objet !. malloc alloue une zone de mémoire (bas niveau), alors que new initialise un objet ou un tableau d'objets (haut niveau).delete
appelle le destructeur puis rend la mémoire au système. free
ne sait pas ce qu'est un
destructeur, il ne risque donc pas de l'appeler.S'il n'est pas possible d'allouer la mémoire demandée, new lancera l'exception bad_alloc. Par contre, malloc ou calloc se contentera de renvoyer un pointeur nullptr
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
unique_ptr
.
Cet objet, défini dans la stl, définit un "smart pointer", c'est-à-dire un objet dont le seul objectif est la gestion de la mémoire. Un unique_ptr a les caractéristiques suivantes:
Pour construire un unique_ptr en même temps qu'un objet, il est recommandé d'utiliser la fonction:
Le code suivant montre ce que devient notre code tableau défini plus haut lorsqu'on utilise un unique_ptr à la place d'un pointeur ordinaire:
#includeclass tableau { public: tableau(int); private: const size_t taille; unique_ptr<int[]>& buffer; }; tableau::tableau(int s) : taille(s), buffer(make_unique<int[]>(taille)){}; }; void main() { tableau t1(1000); tableau t2(100000000000000000); };
Pour aller plus loin, vous pouvez aller voir ces exercices.
Egalement dans la stl, une autre sorte de smart pointer: Un shared_ptr a les caractéristiques suivantes:
Pour construire un shared_ptr en même temps qu'un objet, il est recommandé d'utiliser la fonction:
On l'a vu au chapitre sur l'héritage: un tableau ou mieux un conteneur (par exemple un vecteur) d'objets poylmorphes doit obligatoirement contenir des shared_ptr.
Voir un programme-jouet ici
L'objet ofstream permet d'ouvrir un fichier en écriture, le fichier sera fermé lorsque l'objet sortira de la portée. Le code suivant peut être copié-collé telquel, à chaque exécution il ajoute 10 lignes au fichier "fichier.txt":
#include <fstream> #include <iostream> using namespace std; void ecrireFichierSiPossible(const char* nom=nullptr) { string msg = "bonjour "; int i = 0; if (nom!=nullptr) { ofstream output(nom,ios_base::app); // Ouvrir en mode APPEND if (output) // Si on a pu l'ouvrir { for (;i<10;++i) { output << msg << i << endl; if (output.fail()) // Si probleme IO, on sort { cerr << "Erreur d'ecriture" << endl; break; } } } } // ...et le fichier est ferme ICI ! } int main() { ecrireFichierSiPossible("fichier.txt"); }
L'objet ifstream permet d'ouvrir un fichier en lecture, le fichier sera fermé lorsque l'objet sortira de la portée. Le code ci-dessous, lui aussi copiable-et-compilable, relit partiellement le fichier précédent et imprime ce qu'il a lu.
#include <<fstream>> #include <<iostream>> using namespace std; main() { ifstream input("fichier.txt"); // Ouverture du fichier string mot1,mot2; if (input) { // Si le fichier a pu etre ouvert while(true) { input >> mot1 >> mot2; if ( input.eof()) break; // On sort si le fichier est fini cout << mot1 + " " + mot2 << endl; int pos = input.tellg(); // Ou suis-je ? input.seekg(pos+10); // On saute 10 caracteres if ( input.eof()) break; // On sort si la fin de fichiers est atteinte } cout << "j'ai tout lu" << endl; } else { cout << "pas pu ouvrir" << endl; } } // Le fichier est ferme ICI
Le chapitre sur la bibliothèque standard abord la question des entrées-sorties de manière plus approfondie.
Le point commun entre les pointeurs intelligents et les objets type ifstream est que ces objets n'ont qu'une seule fonction: gérer une ressource. Cette manière de programmer, efficace et sûre, correspond à la technique de programmation C++ appelée RAII: Resource Acquisition Is Initialisation. cf. https://code.i-harness.com/fr/docs/cpp/language/raii
Les trois principes fondamentaux pour gérer les ressources sont:
unique_ptr
, shared_ptr
, ifstream
ou ofstream
sont des exemples d'objets dont l'unique raison
d'être est la gestion d'une ressource.