Gestion des ressources: mémoire, fichiers,...

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

Qu'est-ce que l'allocation dynamique de mémoire ?

Nous avons vu précédemment que la durée de vie d'une variable s'étendait durant toute la portée avant 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).

ATTENTION 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.

Pointeurs empilés, objets entassés

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.

Objets perdus et fuites de 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.

Les pointeurs qui pendouillent

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.

Propriétaires et référents

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.

top


Opérateurs et fonctions

Les opérateurs 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;
};

Les opérateurs 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.

ATTENTION 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.

ATTENTIONLa taille peut parfaitement être une variable, comme on le voit dans cet exemple.

Fonctions malloc, free, realloc

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:

En cas d'erreur d'allocation

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.

Allocation mémoire et constructeurs-destructeurs

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.

Constructeurs de copie

Attention au constructeur de copie; à chaque copie, il faudra prendre une décision; il peut en effet se présenter plusieurs cas de figure:

  1. L'objet source est propriétaire d'un objet référent, l'objet copié pointe vers le référent sans être propriétaire.
  2. L'objet source est propriétaire d'un référent, à la suite de la copie l'objet copié devient le nouveau propriétaire.
  3. Le référent de l'objet source est copié dans une autre zône mémoire, et l'objet copié est propriétaire de la copie du référent.

ATTENTION 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 apres. La seconde solution peut être implémentée par un unique_ptr.

top


Objets utilisés pour la gestion de la mémoire

L'objet 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:

est toujours responsable de l'objet sur lequel il pointe. Un unique_ptr ne peut pas être copié (mais il peut être déplacé). En effet si on copie un pointeur, on se retrouve avec deux pointeurs qui sont chacun responsables de l'objet pointé, ce qui est interdit. Alors que si on déplace un pointeur, on passe simplement la responsabilité de l'un vers l'autre.

.

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:

#include 
class 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.

top


L'objet shared_ptr

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

top


Objets utilisés pour la gestion des fichiers

Ouvrir un fichier en écriture

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");
}

Ouvrir un fichier en lecture

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.

top


Objets gestionnaires de ressources

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

REGLE  D'OR Les trois principes fondamentaux pour gérer les ressources sont:

Propriété
A chaque ressource allouée, correspond un et un seul objet gestionnaire, qui sera propriétaire de cette ressource.
Responsabilité
L'objet gestionnaire est responsable de la ressource, et il est le seul responsable
Simplicité
L'objet gestionnaire ne fait rien d'autre.

unique_ptr, shared_ptr, ifstream ou ofstream sont des exemples d'objets dont l'unique raison d'être est la gestion d'une ressource.

top


Produire du code robuste, simple à maintenir, lisible

Mon code C++ idéal: pas de fuites de mémoire, lisible, performant

top


xhtml Licence Creative Commons Emmanuel Courcelle <emmanuel.courcelle@inp-toulouse.fr>