IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Penser en C++

Volume 1

Penser en C++

Volume 1


précédentsommairesuivant

13. Création d'Objets Dynamiques

Parfois vous connaissez l'exacte quantité, le type, et la durée de vie des objets dans votre programme. Mais pas toujours.

Combien d'avions un système de contrôle du traffic aérien aura-t-il à gérer? Combien de formes différentes utilisera un système de DAO? Combien y aura-t-il de noeuds dans un réseau?

Pour résoudre ce problème général de programmation, il est essentiel d'être capable de créer et de détruire les objets en temps réel (en cours d'exécution). Bien entendu, C a toujours proposé les fonctions d' allocation de mémoire dynamiquemalloc( ) et free( )(ainsi que quelques variantes de malloc( )) qui allouent de la mémoire sur le tas(également appelé espace de stockage libre) au moment de l'éxecution.

Cependant, ceci ne marchera tout simplement pas en C++. Le constructeur ne vous permet pas de manipuler l'adresse de la mémoire à initialiser ,et pour une bonne raison. Si vous pouviez faire cela, vous pourriez:

  1. Oublier. Dans ce cas l'initialisation des objets en C++ ne serait pas garantie.
  2. Accidentellement faire quelque chose à l'objet avant de l'initialiser, espérant que la chose attendue se produira.
  3. Installer un objet d'une taille inadéquate.

Et bien sûr, même si vous avez tout fait correctement, toute personne qui modifie votre programme est susceptible de faire les mêmes erreurs. Une initialisation incorrecte est responsable d'une grande part des problèmes de programmation, de sorte qu'il est spécialement important de garantir des appels de constructeurs pour les objets créés sur le tas.

Aussi comment C++ réussit-il à garantir une initialisation correcte ainsi qu'un nettoyage, tout en vous permettant de créer des objets dynamiquement sur le tas?

La réponse est: en apportant la création d'objet dynamique au coeur du langage. malloc( ) et free( ) sont des fonctions de bibliothèque, elles se trouvent ainsi en dehors du contrôle direct du compilateur. Toutefois, si vous avez un opérateur pour réaliser l'action combinée d'allocation dynamique et et d'initialisation et un autre opérateur pour accomplir l'action combinée de nettoyage et de restitution de mémoire, le compilateur peut encore guarantir que les constructeurs et les destructeurs seront appelés pour tous les objets.

Dans ce chapitre, vous apprendrez comment les new et delete de C++ résolvent élégamment ce problème en créant des objets sur le tas en toute sécurité.

13.1. Création d'objets

Lorsqu'un objet C++ est créé, deux événements ont lieu:

  1. Un espace mémoire est alloué pour l'objet.
  2. Le constructeur est appelé pour initialiser cette zone.

Maintenant vous devriez croire que la seconde étape a toujours lieu. C++ l'impose parce que les objets non initialisés sont une source majeure de bogues de programmes. Où et comment l'objet est créé n'a aucune importance – le constructeur est toujours appelé.

La première étape, cependant, peut se passer de plusieurs manières, ou à différents moments :

  1. L'espace peut être alloué avant que le programme commence, dans la zone de stockage statique. Ce stockage existe pour toute la durée du programme.
  2. L'allocation peut être créée sur la pile à chaque fois qu'un point d'exécution particulier est atteint (une accolade ouvrante). Ce stockage est libéré automatiquement au point d'exécution complémentaire (l'accolade fermante). Ces opérations d'allocation sur la pile font partie de la logique câblée du jeu d'instructions du processeur et sont très efficaces. Toutefois, il vous faut connaître exactement de combien de variables vous aurez besoin quand vous êtes en train d'écrire le programme de façon que le compilateur puisse générer le code adéquat.
  3. L'espace peut être alloué depuis un segment de mémoire appelé le 'tas' (aussi connu comme " free store"). Ceci est appelé l'allocation dynamique de la mémoire. Pour allouer cette mémoire, une fonction est appelée au moment de l'exécution ; cela signifie que vous pouvez décider à n'importe quel moment que vous voulez de la mémoire et combien vous en voulez. Vous êtes également responsable de déterminer quand libérer la mémoire, ce qui signifie que la durée de vie de cette mémoire peut être aussi longue que vous le désirez – ce n'est pas déterminé par la portée.

Souvent ces trois régions sont placées dans un seul segment contigu de mémoire physique : la zone statique, la pile, et le tas (dans un ordre déterminé par l'auteur du compilateur). Néammoins, il n'y a pas de règles. La pile peut être dans un endroit particulier, et le tas peut être implémenté en faisant des appels à des tronçons de mémoire depuis le système d'exploitation. En tant que programmeur, ces choses sont normallement protégées de vous, aussi la seule chose à laquelle vous avez besoin de penser est que la mémoire est là quand vous la demandez.

13.1.1. L'approche du C au tas

Pour allouer de la mémoire dynamiquement au moment de l'exécution, C fournit des fonctions dans sa bibliothèque standard : malloc( ) et ses variantes calloc( ) et realloc( ) pour produire de la mémoire du tas, et free( ) pour rendre la mémoire au tas. Ces fonctions sont pragmatiques mais primitives et nécessitent de la compréhension et du soin de la part du programmeur. Pour créer une instance d'une classe sur le tas en utilisant les fonctions de mémoire dynamique du C, il vous faudrait faire quelque chose comme ceci :

 
Sélectionnez
//: C13:MallocClass.cpp
// Malloc avec des classes
// Ce qu'il vous faudrait faire en l'absence de "new"
#include "../require.h"
#include <cstdlib> // malloc() & free()
#include <cstring> // memset()
#include <iostream>
using namespace std;
 
class Obj {
  int i, j, k;
  enum { sz = 100 };
  char buf[sz];
public:
  void initialize() { // impossible d'utiliser un constructeur
    cout << "initialisation de Obj" << endl;
    i = j = k = 0;
    memset(buf, 0, sz);
  }
  void destroy() const { // impossible d'utiliser un destructeur
    cout << "destruction de Obj" << endl;
  }
};
 
int main() {
  Obj* obj = (Obj*)malloc(sizeof(Obj));
  require(obj != 0);
  obj->initialize();
  // ... un peu plus tard :
  obj->destroy();
  free(obj);
} ///:~

Vous pouvez voir l'utilisation de malloc( ) pour créer un stockage pour l'objet à la ligne :

 
Sélectionnez
Obj* obj = (Obj*)malloc(sizeof(Obj));

Ici, l'utilisateur doit déterminer la taille de l'objet (une occasion de se tromper). malloc( ) retourne un void* parce qu'il produit un fragment de mémoire, pas un objet. C++ ne permet pas à un void* d'être affecté à tout autre pointeur, il doit donc être transtypé.

Parce que malloc( ) peut échouer à trouver de la mémoire (auquel cas elle retourne zéro), vous devez vérifier le pointeur retourné pour vous assurer que l'opération a été un succès.

Mais le pire problème est cette ligne :

 
Sélectionnez
Obj->initialize();

A supposer que les utilisateurs aient tout fait correctement jusque là, ils doivent se souvenir d'initialiser l'objet avant qu'il soit utilisé. Notez qu'un constructeur n'a pas été utilisé parce qu'un constructeur ne peut pas être appelé explicitement (50)– il est appelé pour vous par le compilateur quand un objet est créé. Le problème ici est que l'utilisateur a maintenant la possibilité d'oublier d'accomplir l'initialisation avant que l'objet soit utilisé, réintroduisant ainsi une source majeure d'erreurs.

Il s'avère également que de nombreux programmeurs semblent trouver les fonctions de mémoire dynamique du C trop peu claires et compliquées ; il n'est pas rare de trouver des programmeurs C qui utilisent des machines à mémoire virtuelle allouant de très grands tableaux de variables dans la zone statique pour éviter de réfléchir à l'allocation dynamique. Parce que C++ essaie de rendre l'utilisation de bibliothèques sûre et sans effort pour le programmeur occasionnel, l'approche de C à la mémoire dynamique n'est pas acceptable.

13.1.2. l'operateur new

La solution en C++ est de combiner toutes les actions nécessaires pour créer un objet en un unique opérateur appelé new. Quand vous créez un objet avec new(en utilisant une expression new), il alloue suffisamment d'espace sur le tas pour contenir l'objet et appelle le constructeur pour ce stockage. Ainsi, si vous dites

 
Sélectionnez
MyType *fp = new MyType(1,2);

à l'exécution, l'équivalent de malloc(sizeof(MyType)) est appelé (souvent, c'est littéralement un appel à malloc( )), et le constructeur pour MyType est appelé avec l'adresse résultante comme pointeur this, utilisant (1,2) comme liste d'arguments. A ce moment le pointeur est affecté à fp, c'est un objet vivant, initialisé ; vous ne pouvez même pas mettre vos mains dessus avant cela. C'est aussi automatiquement le type MyType adéquat de sorte qu'aucun transtypage n'est nécessaire.

l'opérateur new par défaut vérifie pour s'assurer que l'allocation de mémoire est réussie avant de passer l'adresse au constructeur, de sorte que vous n'avez pas à déterminer explicitement si l'appel a réussi. Plus tard dans ce chapitre, vous découvrirez ce qui se passe s'il n'y a plus de mémoire disponible.

Vous pouvez créer une expression-new en utilisant n'importe quel constructeur disponible pour la classe. Si le constructeur n'a pas d'arguments, vous écrivez l'expression-new sans liste d'argument du constructeur:

 
Sélectionnez
MyType *fp = new MyType;

Remarquez comment le processus de création d'objets sur le tas devient simple ; une simple expression, avec tout les dimensionnements, les conversions et les contrôles de sécurité integrés. Il est aussi facile de créer un objet sur le tas que sur la pile.

13.1.3. l'opérateur delete

Le complément de l'expression-new est l' expression-delete, qui appelle d'abord le destructeur et ensuite libère la mémoire (souvent avec un appel à free( )). Exactement comme une expression-new retourne un pointeur sur l'objet, une expression-delete nécessite l'adresse d'un objet.

 
Sélectionnez
delete fp;

Ceci détruit et ensuite libère l'espace pour l'objet MyType alloué dynamiquement qui a été créé plus tôt.

delete peut être appelé seulement pour un objet créé par new. Si vous malloc(ez)( )(ou calloc(ez)( ) ou realloc(ez)( )) un objet et ensuite vous le delete(z), le comportement est indéfini. Parce que la plupart des implémentations par défaut de new et delete utilisent malloc( ) et free( ), vous finirez probablement par libérer la mémoire sans appeler le destructeur.

Si le pointeur que vous détruisez vaut zéro, rien ne se passera. Pour cette raison, les gens recommandent souvent de mettre un pointeur à zero immédiatemment après un 'delete', pour empêcher de le détruire deux fois. Détruire un objet plus d'une fois est assurement une mauvaise chose à faire, et causera des problèmes.

13.1.4. Un exemple simple

Cet exemple montre que l'initialisation a lieu :

 
Sélectionnez
//: C13:Tree.h
#ifndef TREE_H
#define TREE_H
#include <iostream>
 
class Tree {
  int height;
public:
  Tree(int treeHeight) : height(treeHeight) {}
  ~Tree() { std::cout << "*"; }
  friend std::ostream&
  operator<<(std::ostream& os, const Tree* t) {
    return os << "La hauteur de l'arbre est : "
              << t->height << std::endl;
  }
}; 
#endif // TREE_H ///:~
 
Sélectionnez
//: C13:NewAndDelete.cpp
// Démo simple de new & delete
#include "Tree.h"
using namespace std;
 
int main() {
  Tree* t = new Tree(40);
  cout << t;
  delete t;
} ///:~

Vous pouvez prouver que le consructeur est appelé en affichant la valeur de Tree. Ici, c'est fait en surchargeant l'opérateur operator<< pour l'utiliser avec un ostream et un Tree*. Notez, toutefois, que même si la fonction est déclarée comme friend, elle est définie 'inline' ! C'est pour des raisons d'ordre purement pratique– la définition d'une fonction amie d'une classe comme une 'inline' ne change pas le statut d' amie ou le fait que c'est une fonction globale et non une fonction membre de classe. Notez également que la valeur de retour est le résultat de toute l'expression de sortie, qui est un ostream&(ce qu'il doit être, pour satisfaire le type de valeur de retour de la fonction).

13.1.5. Le surcoût du gestionnaire de mémoire

Lorsque vous créez des objets automatiques sur la pile, la taille des objets et leur durée de vie sont intégrées immédiatement dans le code généré, parce que le compilateur connaît le type exact, la quantité, et la portée. La création d'objets sur le tas implique un surcoût à la fois dans le temps et dans l'espace. Voici un scenario typique. (Vous pouvez remplacer malloc( ) par calloc( ) ou realloc( ).)

Vous appelez malloc( ), qui réclame un bloc de mémoire dans le tas. (Ce code peut, en réalité, faire partie de malloc( ).)

Une recherche est faite dans le tas pour trouver un bloc de mémoire suffisamment grand pour satisfaire la requête. Cela est fait en consultant une carte ou un répertoire de quelque sorte montrant quels blocs sont actuellement utilisés et quels blocs sont disponibles. C'est un procédé rapide, mais qui peut nécessiter plusieurs essais, aussi il peut ne pas être déterministe – c'est à dire que vous ne pouvez pas compter sur le fait que malloc( ) prenne toujours le même temps pour accomplir son travail.

Avant qu'un pointeur sur ce bloc soit retourné, la taille et l'emplacement du bloc doivent être enregistrés de sorte que des appels ultérieurs à malloc( ) ne l'utiliseront pas, et de sorte que lorsque vous appelez free( ), le système sache combien de mémoire libérer.

La façon dont tout cela est implémenté peut varier dans de grandes proportions. Par exemple, rien n'empêche que des primitives d'allocations de mémoire soit implémentées dans le processeur. Si vous êtes curieux, vous pouvez écrire des programmes de test pour essayer de deviner la façon dont votre malloc( ) est implémenté. Vous pouvez aussi lire le code source de la bibliothèque, si vous l'avez (les sources GNU sont toujours disponibles).

13.2. Exemples précédents revus

En utilisant new et delete, l'exemple Stash introduit précédemment dans ce livre peut être réécrit en utilisant toutes les fonctionalités présentées dans ce livre jusqu'ici. Le fait d'examiner le nouveau code vous donnera également une revue utile de ces sujets .

A ce point du livre, ni la classe Stash ni la classe Stack ne “posséderont” les objets qu'elles pointent ; c'est à dire que lorsque l'objet Stash ou Stack sort de la portée, il n'appellera pas delete pour tous les objets qu'il pointe. La raison pour laquelle cela n'est pas possible est que, en tentant d'être génériques, ils conservent des pointeurs void. Si vous detruisez un pointeur void, la seule chose qui se produit est la libération de la mémoire, parce qu'il n'y a pas d'informaton de type et aucun moyen pour le compilateur de savoir quel destructeur appeler.

13.2.1. detruire un void* est probablement une erreur

Cela vaut la peine de relever que si vous appelez delete sur un void*, cela causera presque certainement une erreur dans votre programme à moins que la destination de ce pointeur ne soit très simple ; en particulier, il ne doit pas avoir de destructeur. Voici un exemple pour vous montrer ce qui se passe :

 
Sélectionnez
//: C13:BadVoidPointerDeletion.cpp
// detruire des pointeurs void peut provoquer des fuites de mémoire
#include <iostream>
using namespace std;
 
class Object {
  void* data; // un certain stockage
  const int size;
  const char id;
public:
  Object(int sz, char c) : size(sz), id(c) {
    data = new char[size];
    cout << "Construction de l'objet " << id 
         << ", taille = " << size << endl;
  }
  ~Object() { 
    cout << "Destruction de l'objet " << id << endl;
    delete []data; // OK libérer seulement la donnée,
    // aucun appel de destructeur nécessaire
  }
};
 
int main() {
  Object* a = new Object(40, 'a');
  delete a;
  void* b = new Object(40, 'b');
  delete b;
} ///:~

La classe Object contient un void* qui est initialisé pour une donnée “brute” (il ne pointe pas sur des objets ayant un destructeur). Dans le destructeur de Object, delete est appelé pour ce void* avec aucun effet néfaste, parce que la seule chose que nous ayons besoin qu'il se produise est que cette mémoire soit libérée.

Cependant, dans main( ) vous pouvez voir qu'il est tout à fait nécessaire que delete sache avec quel type d'objet il travaille. Voici la sortie :

 
Sélectionnez
Construction de l'objet a, taille = 40
Destruction de l'objet a
Construction de l'objet b, taille = 40

Parce que delete a sait que a pointe sur un Object, le destructeur est appelé et le stockage alloué pour data est libérée. Toutefois, si vous manipulez un objet à travers un void* comme dans le cas de delete b, la seule chose qui se produit est que la mémoire pour l' Object est libérée ; mais le destructeur n'est pas appelé de sorte qu'il n'y a pas libération de la mémoire que data pointe. A la compilation de ce programme, vous ne verrez probablement aucun message d'avertissement ; le compilateur suppose que vous savez ce que vous faites. Aussi vous obtenez une fuite de mémoire très silencieuse.

Si vous avez une fuite de mémoire dans votre programme, recherchez tous les appels à delete et vérifiez le type de pointeur qui est détruit. Si c'est un void* alors vous avez probablement trouvé une source de votre fuite de mémoire (C++ offre, cependant, d'autres possibilités conséquentes de fuites de mémoire).

13.2.2. La responsabilité du nettoyage avec les pointeurs

Pour rendre les conteneurs Stash et Stack flexibles (capables de contenir n'importe quel type d'objet), ils conserveront des pointeurs void. Ceci signifie que lorsqu'un pointeur est retourné par l'objet Stash ou Stack, vous devez le transtyper dans le type convenablebavant de pouvoir l'utiliser ; comme vu auparavant, vous devez également le transtyper dans le type convenable avant de le detruire ou sinon vous aurez une fuite de mémoire.

L'autre cas de fuite de mémoire concerne l'assurance que delete est effectivement appelé pour chaque pointeur conservé dans le conteneur. Le conteneur ne peut pas “posséder” le pointeur parce qu'il le détient comme un void* et ne peut donc pas faire le nettoyage correct. L'utilisateur doit être responsable du nettoyage des objets. Ceci produit un problème sérieux si vous ajoutez des pointeurs sur des objets créés dans la pile et des objets créés sur le tas dans le même conteneur parce qu'une expression-delete est dangereuse pour un pointeur qui n'a pas été créé sur le tas. (Et quand vous récupérez un pointeur du conteneur, comment saurez vous où son objet a été alloué ?) Ainsi, vous devez vous assurer que les objets conservés dans les versions suivantes de Stash et Stack ont été créés seulement sur le tas, soit par une programmation méticuleuse ou en créant des classes ne pouvant être construites que sur le tas.

Il est également important de s'assurer que le programmeur client prend la responsabilité du nettoyage de tous les pointeurs du conteneur. Vous avez vu dans les exemples précédents comment la classe Stack vérifie dans son destructeur que tous les objets Link ont été dépilés. Pour un Stash de pointeurs, toutefois, une autre approche est nécessaire.

13.2.3. Stash pour des pointeurs

Cette nouvelle version de la classe Stash, nommée PStash, détient des pointers sur des objets qui existent eux-mêmes sur le tas, tandis que l'ancienne Stash dans les chapitres précédents copiait les objets par valeur dans le conteneur Stash. En utilisant new et delete, il est facile et sûr de conserver des pointeurs vers des objets qui ont été créés sur le tas.

Voici le fichier d'en-tête pour le “ Stash de pointeurs”:

 
Sélectionnez
//: C13:PStash.h
// Conserve des pointeurs au lieu d'objets
#ifndef PSTASH_H
#define PSTASH_H
 
class PStash {
  int quantity; //Nombre d'espaces mémoire
  int next; // Espace vide suivant
   // Stockage des pointeurs:
  void** storage;
  void inflate(int increase);
public:
  PStash() : quantity(0), storage(0), next(0) {}
  ~PStash();
  int add(void* element);
  void* operator[](int index) const; // Récupération
  // Enlever la référence de ce PStash:
  void* remove(int index);
  // Nombre d'éléments dans le Stash:
  int count() const { return next; }
};
#endif // PSTASH_H ///:~

Les éléments de données sous-jacents sont assez similaires, mais maintenant le stockage est un tableau de pointeurs void, et l'allocation de mémoire pour ce tableau est réalisé par new au lieu de malloc( ). Dans l'expression

 
Sélectionnez
void** st = new void*[quantity + increase];

le type d'objet alloué est un void*, ainsi l'expression alloue un tableau de pointeurs void.

Le destructeur libère la mémoire où les pointeurs void sont stockés plutôt que d'essayer de libérer ce qu'ils pointent (ce qui, comme on l'a remarqué auparavant, libérera leur stockage et n'appellera pas les destructeurs parce qu'un pointeur void ne comporte aucune information de type).

L'autre changement est le remplacement de la fonction fetch( ) par operator[ ], qui est syntaxiquement plus expressif. De nouveau, toutefois, un void* est retourné, de sorte que l'utilisateur doit se souvenir des types rassemblés dans le conteneur et transtyper les pointeurs au fur et à mesure de leur récupération (un problème auquel nous apporterons une solution dans les chapitres futurs).

Voici les définitions des fonctions membres :

 
Sélectionnez
//: C13:PStash.cpp {O}
// définitions du Stash de Pointeurs 
#include "PStash.h"
#include "../require.h"
#include <iostream>
#include <cstring> // fonctions 'mem' 
using namespace std;
 
int PStash::add(void* element) {
  const int inflateSize = 10;
  if(next >= quantity)
    inflate(inflateSize);
  storage[next++] = element;
  return(next - 1); // Indice
}
 
// Pas de propriété:
PStash::~PStash() {
  for(int i = 0; i < next; i++)
    require(storage[i] == 0, 
      "PStash n'a pas été nettoyé");
  delete []storage; 
}
 
// Surcharge d'opérateur pour accès
void* PStash::operator[](int index) const {
  require(index >= 0,
    "PStash::operator[] indice négatif");
  if(index >= next)
    return 0; // Pour signaler la fin
  // Produit un pointeur vers l'élément désiré:
  return storage[index];
}
 
void* PStash::remove(int index) {
  void* v = operator[](index);
  // "Enlève" le pointeur:
  if(v != 0) storage[index] = 0;
  return v;
}
 
void PStash::inflate(int increase) {
  const int psz = sizeof(void*);
  void** st = new void*[quantity + increase];
  memset(st, 0, (quantity + increase) * psz);
  memcpy(st, storage, quantity * psz);
  quantity += increase;
  delete []storage; // Ancien emplacement
  storage = st; // Pointe sur un nouvel emplacement
} ///:~

La fonction add( ) est effectivement la même qu'avant, sauf qu'un pointeur est stocké plutôt qu'une copie de l'objet entier.

Le code de inflate( ) est modifié pour gérer l'allocation d'un tableau de void* à la différence de la conception précédente, qui ne fonctionnait qu'avec des octets 'bruts'. Ici, au lieu d'utiliser l'approche précédente de copier par indexation de tableau, la fonction de la librairie standard C memset( ) est d'abord utilisée pour mettre toute la nouvelle mémoire à zéro (ceci n'est pas strictement nécessaire, puisqu'on peut supposer que le PStash gère toute la mémoire correctement– mais en général cela ne fait pas de mal de prendre quelques précautions supplémentaires). Ensuite memcpy( ) déplace les données existantes de l'ancien emplacement vers le nouveau. Souvent, des fonctions comme memset( ) et memcpy( ) ont été optimisées avec le temps, de sorte qu'elles peuvent être plus rapides que les boucles montrées précédemment. Mais avec une fonction comme inflate( ) qui ne sera probablement pas utilisée très souvent il se peut que vous ne perceviez aucune différence de performance. Toutefois, le fait que les appels de fonction soient plus concis que les boucles peut aider à empêcher des erreurs de codage.

Pour placer la responsabilité du nettoyage d'objets carrément sur les épaules du programmeur client, il y a deux façons d'accéder aux pointeurs dans le PStash: l' operator[], qui retourne simplement le pointeur mais le conserve comme membre du conteneur, et une seconde fonction membre remove( ), qui retourne également le pointeur, mais qui l'enlève également du conteneur en affectant zéro à cette position. Lorsque le destructeur de PStash est appelé, il vérifie pour s'assurer que tous les pointeurs sur objets ont été enlevés ; dans la négative, vous êtes prévenu pour pouvoir empêcher une fuite de mémoire (des solutions plus élégantes viendront à leur tour dans les chapitres suivants).

Un test

Voici le vieux programme de test pour Stash récrit pour PStash:

 
Sélectionnez
//: C13:PStashTest.cpp
//{L} PStash
// Test du Stash de pointeurs
#include "PStash.h"
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
 
int main() {
  PStash intStash;
  // 'new' fonctionnne avec des types prédéfinis, également. Notez
  // la syntaxe "pseudo-constructor":
  for(int i = 0; i < 25; i++)
    intStash.add(new int(i));
  for(int j = 0; j < intStash.count(); j++)
    cout << "intStash[" << j << "] = "
         << *(int*)intStash[j] << endl;
  // Nettoyage :
  for(int k = 0; k < intStash.count(); k++)
    delete intStash.remove(k);
  ifstream in ("PStashTest.cpp");
  assure(in, "PStashTest.cpp");
  PStash stringStash;
  string line;
  while(getline(in, line))
    stringStash.add(new string(line));
  // Affiche les chaînes :
  for(int u = 0; stringStash[u]; u++)
    cout << "stringStash[" << u << "] = "
         << *(string*)stringStash[u] << endl;
  // Nettoyage :
  for(int v = 0; v < stringStash.count(); v++)
    delete (string*)stringStash.remove(v);
} ///:~

Commes auparavant, les Stash s sont créés et remplis d'information, mais cette fois l'information est constituée des pointeurs résultant d'expressions- new. Dans le premier cas, remarquez la ligne:

 
Sélectionnez
intStash.add(new int(i));

L'expression new int(i) utilise la forme pseudo-constructeur, ainsi le stockage pour un nouvel objet int est créé sur le tas, et le int est initialisé avec la valeur i.

Pendant l'affichage, la valeur retournée par PStash::operator[] doit être transtypée dans le type adéquat; ceci est répété pour le reste des autres objets PStash dans le programme. C'est un effet indésirable d'utiliser des pointeurs void comme représentation sous-jacente et ce point sera résolu dans les chapitres suivants.

Le second test ouvre le fichier du code source et le lit ligne par ligne dans un autre PStash. Chaque ligne est lue dans un objet string en utilisant getline( ), ensuite un nouveaustring est créé depuis line pour faire une copie indépendante de cette ligne. Si nous nous étions contentés de passer l'adresse de line à chaque fois, nous nous serions retrouvés avec un faisceau de pointeurs pointant tous sur line, laquelle contiendrait la dernière ligne lue du fichier.

Lorsque vous récupérez les pointeurs, vous voyez l'expression :

 
Sélectionnez
*(string*)stringStash[v]

Le pointeur retourné par operator[] doit être transtypé en un string* pour lui donner le type adéquat. Ensuite, le string* est déréférencé de sorte que l'expression est évaluée comme un objet, à ce moment le compilateur voit un objet string à envoyer à cout.

Les objets créés sur le tas doivent être détruits par l'utilisation de l'instruction remove( ) ou autrement vous aurez un message au moment de l'exécution vous disant que vous n'avez pas complètement nettoyé les objets dans le PStash. Notez que dans le cas des pointeurs sur int, aucun transtypage n'est nécessaire parce qu'il n'y a pas de destructeur pour un int et tout ce dont nous avons besoin est une libération de la mémoire :

 
Sélectionnez
delete intStash.remove(k);

Néammoins, pour les pointeurs sur string, si vous oubliez de transtyper vous aurez une autre fuite de mémoire (silencieuse), de sorte que le transtypage est essentiel :

 
Sélectionnez
delete (string*)stringStash.remove(k);

Certains de ces problèmes (mais pas tous) peuvent être résolus par l'utilisation des 'templates' (que vous étudierez dans le chapitre 16).

13.3. new & delete pour les tableaux

En C++, vous pouvez créer des tableaux d'objets sur la pile ou sur le tas avec la même facilité, et (bien sûr) le constructeur est appelé pour chaque objet dans le tableau. Il y a une contrainte, toutefois : il doit y avoir un constructeur par défaut, sauf pour l'initialisation d'agrégat sur la pile (cf. Chapitre 6), parce qu'un constructeur sans argument doit être appelé pour chaque objet.

Quand vous créez des tableaux d'objets sur le tas en utilisant new, vous devez faire autre chose. Voici un exemple d'un tel tableau :

 
Sélectionnez
MyType* fp = new MyType[100];

Ceci alloue suffisamment d'espace de stockage sur le tas pour 100 objets MyType et appelle le constructeur pour chacun d'eux. Cependant, à présent vous ne disposez que d'un MyType*, ce qui est exactement la même chose que vous auriez eu si vous aviez dit :

 
Sélectionnez
MyType* fp2 = new MyType;

pour créer un seul objet. Parce que vous avez écrit le code, vous savez que fp est en fait l'adresse du début d'un tableau, et il paraît logique de sélectionner les éléments d'un tableau en utilisant une expression comme fp[3]. Mais que se passe-t-il quand vous détruisez le tableau ? Les instructions

 
Sélectionnez
delete fp2; // OK
delete fp;  // N'a pas l'effet désiré

paraissent identiques, et leur effet sera le même. Le destructeur sera appelé pour l'objet MyType pointé par l'adresse donnée, et le stockage sera libéré. Pour fp2 cela fonctionne, mais pour fp cela signifie que 99 appels au destructeur ne seront pas effectués. La bonne quantité d'espace de stockage sera toujours libérée, toutefois, parce qu'elle est allouée en un seul gros morceau, et la taille de ce morceau est cachée quelque part par la routine d'allocation.

La solution vous impose de donner au compilateur l'information qu'il s'agit en fait de l'adresse du début d'un tableau. Ce qui est fait avec la syntaxe suivante :

 
Sélectionnez
delete []fp;

Les crochets vides disent au compilateur de générer le code qui cherche le nombre d'objets dans le tableau, stocké quelque part au moment de la création de ce dernier, et appelle le destructeur pour ce nombre d'objets. C'est en fait une version améliorée de la syntaxe ancienne, que vous pouvez toujours voir dans du vieux code :

 
Sélectionnez
delete [100]fp;

qui forçait le programmeur à inclure le nombre d'objets dans le tableau et introduisait le risque que le programmeur se trompe. Le fait de laisser le compilateur gérer cette étape consommait un temps système supplémentaire très bas, et il a été décidé qu'il valait mieux ne préciser qu'en un seul endroit plutôt qu'en deux le nombre d'objets.

13.3.1. Rendre un pointeur plus semblable à un tableau

A côté de cela, le fp défini ci-dessus peut être modifié pour pointer sur n'importe quoi, ce qui n'a pas de sens pour l'adresse de départ d'un tableau. Il est plus logique de le définir comme une constante, si bien que toute tentative de modifier le pointeur sera signalée comme une erreur. Pour obtenir cet effet, vous pouvez essayer

 
Sélectionnez
int const* q = new int[10];

ou bien

 
Sélectionnez
const int* q = new int[10];

mais dans les deux cas, le const sera lié au int, c'est-à-dire ce qui est pointé, plutôt qu'à la qualité du pointeur lui-même. Au lieu de cela, vous devez dire :

 
Sélectionnez
int* const q = new int[10];

A présent, les éléments du tableau dans q peuvent être modifiés, mais tout changement de q(comme q++) est illégal, comme c'est le cas avec un identifiant de tableau ordinaire.

13.4. Manquer d'espace de stockage

Que se passe-t-il quand l' operator new () ne peut trouver de block de mémoire contigü suffisamment grand pour contenir l'objet désiré ? Une fonction spéciale appelée le gestionnaire de l'opérateur new est appelée. Ou plutôt, un pointeur vers une fonction est vérifié, et si ce pointeur ne vaut pas zéro la fonction vers laquelle il pointe est appelée.

Le comportement par défaut pour ce gestionnaire est de lancer une exception, sujet couvert dans le deuxième Volume. Toutefois, si vous utilisez l'allocation sur le tas dans votre programme, il est prudent de remplacer au moins le gestionnaire de 'new' avec un message qui dit que vous manquez de mémoire et ensuite termine le programme. Ainsi, pendant le débugage, vous aurez une idée de ce qui s'est produit. Pour le programme final vous aurez intérêt à utiliser une récupération plus robuste.

Vous remplacez le gestionnaire de 'new' en incluant new.h et en appelant ensuite set_new_handler( ) avec l'adresse de la fonction que vous voulez installer :

 
Sélectionnez
//: C13:NewHandler.cpp
// Changer le gestionnaire de new
#include <iostream>
#include <cstdlib>
#include <new>
using namespace std;
 
int count = 0;
 
void out_of_memory() {
  cerr << "memory exhausted after " << count 
    << " allocations!" << endl;
  exit(1);
}
 
int main() {
  set_new_handler(out_of_memory);
  while(1) {
    count++;
    new int[1000]; // Epuise la mémoire
  }
} ///:~

La fonction gestionnaire de 'new' ne doit prendre aucun argument et avoir une valeur de retour void. La boucle while continuera d'allouer des objets int(et de jeter leurs adresses de retour) jusqu'à ce que le stockage libre soit épuisé. A l'appel à new qui suit immédiatement, aucun espace de stockage ne peut être alloué, et le gestionnaire de 'new' sera appelé.

Le comportement du gestionnaire de 'new' est lié à operator new ( ), donc si vous surchargez operator new ( )(couvert dans la section suivante) le gestionnaire de 'new' ne sera pas appelé par défaut. Si vous voulez toujours que le gestionnaire de 'new' soit appelé vous devrez écrire le code pour ce faire dans votre operator new ( ) surchargé.

Bien sûr, vous pouvez écrire des gestionnaires de 'new' plus sophistiqués, voire même un qui essaye de réclamer de la mémoire (généralement connu sous le nom de ramasse-miettes( garbage collector en anglais, ndt)). Ce n'est pas un travail pour un programmeur novice.

13.5. Surcharger new & delete

Quand vous créez une expression new, deux choses se produisent. D'abord, l'espace de stockage est alloué en utilisant operator new( ), puis le constructeur est appelé. Dans une expression delete, le destructeur est appelé, puis le stockage est libéré en utilisant operator delete( ). Les appels au constructeur et au destructeur ne sont jamais sous votre contrôle (autrement vous pourriez les corrompre accidentellement), mais vous pouvez modifier les fonctions d'allocation de stockage operator new( ) et operator delete( ).

Le système d'allocation de mémoire utilisé par new et delete est conçu pour un usage général. Dans des situations spéciales, toutefois, il ne convient pas à vos besoin. La raison la plus commune pour modifier l'allocateur est l'efficacité : vous pouvez créer et détruire tellement d'objets d'une classe donnée que cela devient goulet d'étranglement en terme de vitesse. Le C++ vous permet de surcharger new et delete pour implémenter votre propre schéma d'allocation de stockage, afin que vous puissiez gérer ce genre de problèmes.

Un autre problème est la fragmentation du tas. En allouant des objets de taille différente, il est possible de fragmenter le tas si bien que vous vous retrouvez à cours de stockage. En fait, le stockage peut être disponible, mais à cause de la fragmentation aucun morceau n'est suffisamment grand pour satisfaire vos besoins. En créant votre propre allocateur pour une classe donnée, vous pouvez garantir que cela ne se produira jamais.

Dans les systèmes embarqués et temps-réel, un programme peut devoir tourner pendant longtemps avec des ressources limitées. Un tel système peut également nécessiter que l'allocation de mémoire prennent toujours le même temps, et il n'y a aucune tolérance à l'épuisement du tas ou à sa fragmentation. Un allocateur de mémoire sur mesure est la solution ; autrement, les programmeurs éviteront carrémnt d'utiliser new et delete dans ce genre de situations et manqueront un des atouts précieux du C++.

Quand vous surchargez operator new( ) et operator delete( ), il est important de se souvenir que vous modifiez uniquement la façon dont l'espace de stockage brut est alloué. Le compilateur appellera simplement votre new au lieu de la version par défaut pour allouer le stockage, puis appellera le constructeur pour ce stockage. Donc, bien que le compilateur alloue le stockage et appelle le constructeur quand il voit new, tout ce que vous pouvez changer quand vous surchargez new est le volet allocation de la mémoire. ( delete a la même limitation.)

Quand vous surchargez operator new( ), vous remplacez également le comportement quand il en vient à manquer de mémoire, vous devez donc décider que faire dans votre operator new( ): renvoyer zéro, écrire une boucle pour appeler le gestionnaire de new et réessayer l'allocation, ou (typiquement) de lancer une exception bad_alloc(traitée dans le Volume 2, disponible sur www.BruceEckel.com en anglais et bientôt sur www.developpez.com en français).

Surcharger new et delete c'est comme surcharger n'importe quel autre opérateur. Toutefois, vous avez le choix de surcharger l'allocateur global ou d'utiliser un allocateur différent pour une classe donnée.

13.5.1. La surcharge globale de new & delete

C'est l'approche extrême, quand les versions globales de new et delete ne sont pas satisfaisantes pour l'ensemble du système. Si vous surchargez les versions globales, vous rendez les versions par défaut complètement innaccessibles – vous ne pouvez même pas les appeler depuis vos redéfinitions.

Le new surchargé doit prendre un argument de type size_t(le type standard pour les tailles en C standard). Cet argument est généré et vous est passé par le compilateur et représente la taille de l'objet que vous avez la charge d'allouer. Vous devez renvoyer un pointeur soit vers un objet de cette taille (ou plus gros, si vous avez des raisons pour ce faire), ou zéro si vous ne pouvez pas trouver la mémoire (auquel cas le constructeur n'est pas appelé !). Toutefois, si vous ne pouvez pas trouver la mémoire, vous devriez probablement faire quelque chose de plus informatif que simplement renvoyer zéro, comme d'appeler new-handler ou de lancer une exception, pour signaler qu'il y a un problème.

La valeur de retour de operator new( ) est un void*, pas un pointeur vers un quelconque type particulier. Tout ce que vous avez fait est allouer de la mémoire, pas un objet fini – cela ne se produit pas tant que le constructeur n'est pas appelé, une action que le compilateur garantit et qui échappe à votre contrôle.

operator delete( ) prend un void* vers la mémoire qui a été allouée par operator new. C'est un void* parce que operator delete ne reçoit le pointeur qu' après que le destructeur ait été appelé, ce qui efface le caractère objet du fragment de stockage. Le type de retour est void.

Voici un exemple simple montrant comment surcharger le new et le delete globaux :

 
Sélectionnez
//: C13:GlobalOperatorNew.cpp
// Surcharge new/delete globaux
#include <cstdio>
#include <cstdlib>
using namespace std;
 
void* operator new(size_t sz) {
  printf("operateur new: %d octets\n", sz);
  void* m = malloc(sz);
  if(!m) puts("plus de memoire");
  return m;
}
 
void operator delete(void* m) {
  puts("operateur delete");
  free(m);
}
 
class S {
  int i[100];
public:
  S() { puts("S::S()"); }
  ~S() { puts("S::~S()"); }
};
 
int main() {
  puts("creation & destruction d'un int");
  int* p = new int(47);
  delete p;
  puts("creation & destruction d'un s");
  S* s = new S;
  delete s;
  puts("creation & destruction de S[3]");
  S* sa = new S[3];
  delete []sa;
} ///:~

Ici vous pouvez voir la forme générale de la surcharge de new et delete. Ceux-ci utilisent les fonctions de librairie du C standard malloc( ) et free( ) pour les allocateurs (qui sont probablement ce qu'utilisent également les new et delete par défaut !). Toutefois, ils affichent également des messages à propos de ce qu'ils font. Remarquez que printf( ) et puts( ) sont utilisés plutôt que iostreams. C'est parce que quand un objet iostream est créé (comme les cin, cout et cerr globaux), il appelle new pour allouer de la mémoire. Avec printf( ) vous ne vous retrouvez pas dans un interblocage parce qu'il n'appelle pas new pour s'initialiser.

Dans main( ), des objets de type prédéfinis sont créés pour prouver que le new et le delete surchargés sont également appelés dans ce cas. Puis un unique objet de type S est créé, suivi par un tableau de S. Pour le tableau, vous verrez par le nombre d'octets réclamés que de la mémoire supplémentaire est allouée pour stocker de l'information (au sein du tableau) à propos du nombre d'objets qu'il contient. Dans tous les cas, les versions surchargées globales de new et delete sont utilisées.

13.5.2. Surcharger new & delete pour une classe

Bien que vous n'ayez pas à dire explicitement static, quand vous surchargez new et delete pour une classe, vous créez des fonctions membres static. Comme précédemment, la syntaxe est la même que pour la surcharge de n'importe quel opérateur. Quand le compilateur voit que vous utilisez new pour créer un objet de votre classe, il choisit le membre operator new( ) de préférence à la définition globale. Toutefois, les versions globales de new et delete sont utilisées pour tous les autres types d'objets (à moins qu'ils n'aient leur propres new et delete).

Dans l'exemple suivant, un système d'allocation primitif est créé pour la classe Framis. Un tronçon de mémoire est réservé dans la zone de données statiques au démarrage du programme, et cette mémoire est utilisée pour allouer de l'espace pour les objets de type Framis. Pour déterminer quels blocs ont été alloués, un simple tableau d'octets est utilisé, un octet pour chaque bloc :

 
Sélectionnez
//: C13:Framis.cpp
// Surcharge locale de new & delete
#include <cstddef> // Size_t
#include <fstream>
#include <iostream>
#include <new>
using namespace std;
ofstream out("Framis.out");
 
class Framis {
  enum { sz = 10 };
  char c[sz]; // Pour occuper de l'espace, pas utilisé
  static unsigned char pool[];
  static bool alloc_map[];
public:
  enum { psize = 100 };  // nombre de framis autorisés
  Framis() { out << "Framis()\n"; }
  ~Framis() { out << "~Framis() ... "; }
  void* operator new(size_t) throw(bad_alloc);
  void operator delete(void*);
};
unsigned char Framis::pool[psize * sizeof(Framis)];
bool Framis::alloc_map[psize] = {false};
 
// La taille est ignorée -- suppose un objet Framis 
void* 
Framis::operator new(size_t) throw(bad_alloc) {
  for(int i = 0; i < psize; i++)
    if(!alloc_map[i]) {
      out << "utilise le bloc " << i << " ... ";
      alloc_map[i] = true; // le marquer utilisé
      return pool + (i * sizeof(Framis));
    }
  out << "plus de memoire" << endl;
  throw bad_alloc();
}
 
void Framis::operator delete(void* m) {
  if(!m) return; // Vérifie si le pointeur est nul
  // Suppose qu'il a été créé dans la réserve
  // Calcule le numéro du bloc :
  unsigned long block = (unsigned long)m
    - (unsigned long)pool;
  block /= sizeof(Framis);
  out << "libère le bloc " << block << endl;
  // le marque libre :
  alloc_map[block] = false;
}
 
int main() {
  Framis* f[Framis::psize];
  try {
    for(int i = 0; i < Framis::psize; i++)
      f[i] = new Framis;
    new Framis; // Plus de mémoire
  } catch(bad_alloc) {
    cerr << "Plus de mémoire !" << endl;
  }
  delete f[10];
  f[10] = 0;
  // Utilise la mémoire libérée :
  Framis* x = new Framis;
  delete x;
  for(int j = 0; j < Framis::psize; j++)
    delete f[j]; // Delete f[10] OK
} ///:~

La réserve de mémoire pour le tas de Framis est créé en allouant un tableau d'octets suffisamment grand pour contenir psize objets Framis. La carte d'allocation fait psize éléments de long, et il y a donc un bool pour chaque bloc. Toutes les valeurs dans la carte d'allocation sont initialisées à false en utilisant l'astuce de l'initialisation d'agrégats qui consiste à affecter le premier élément afin que le compilateur initialise automatiquement tout le reste à leur valeur par défaut (qui est false, dans le cas des bool).

L' operator new( ) local a la même syntaxe que le global. Tout ce qu'il fait est de chercher une valeur false dans la carte d'allocation, puis met cette valeur à true pour indiquer qu'elle a été allouée et renvoie l'adresse du bloc de mémoire correspondant. S'il ne peut trouver aucune mémoire, il émet un message au fichier trace et lance une exception bad_alloc.

C'est le premier exemple d'exceptions que vous avez vu dans ce livre. Comme la discussion détaillée est repoussée au Volume 2, c'en est un usage très simple. Dans l' operator new( ) il y a deux utilisations de la gestion d'exceptions. Premièrement, la liste d'arguments de la fonction est suivie par throw(bad_alloc), qui dit au compilateur et au lecteur que cette fonction peut lancer une exception de type bad_alloc. Deuxièmement, s'il n'y a plus de mémoire la fonction lance effectivement l'exception dans l'instruction throw bad_alloc. Quand une exception est lancée, la fonction cesse de s'exécuter et le contrôle est passé à un gestionnaire d'exécution, qui est exprimé par une clause catch.

Dans main( ), vous voyez l'autre partie de la figure, qui est la clause try-catch. Le bloc try est entouré d'accolades et contient tout le code qui peut lancer des exceptions – dans ce cas, tout appel à new qui invoque des objets de type Framis. Immédiatement après le bloc try se trouvent une ou plusieurs clauses catch, chacune d'entre elles spécifiant le type d'exception qu'elles capturent. Dans ce cas, catch(bad_alloc) signifie que les exceptions bad_alloc seront interceptées ici. Cette clause catch particulière est exécutée uniquement quand une exception bad_alloc est lancée, et l'exécution continue à la fin de la dernière clause catch du groupe (il n'y en a qu'une seule ici, mais il pourrait y en avoir plusieurs).

Dans cet exemple, on peut utiliser iostreams parce que les opérateurs operator new( ) et delete( ) globaux ne sont pas touchés.

operator delete( ) suppose que l'adresse de Framis a été créée dans la réserve. C'est une supposition logique, parce que l' operator new( ) local sera appelé à chaque fois que vous créez un objet Framis unique sur le tas – mais pas un tableau : l'opérateur new global est appelé pour les tableaux. Ainsi, l'utilisateur peut avoir accidentellement appelé l' operator delete( ) sans utiliser la syntaxe avec les crochets vides pour indiquer la destruction d'un tableau. Ceci poserait un problème. Egalement, l'utilisateur pourrait detruire un pointeur vers un objet créé sur la pile. Si vous pensez que ces choses pourraient se produire, vous pourriez vouloir ajouter une ligne pour être sûr que l'adresse est dans la réserve et sur une frontière correcte (peut-être commencez-vous à voir le potentiel des new et delete surchargés pour trouver les fuites de mémoire).

L' operator delete( ) calcule le bloc de la réserve que représente ce pointeur, puis fixe l'indicateur de la carte d'allocation de ce bloc à faux pour indiquer que ce bloc a été libéré.

Dans main( ), suffisamment d'objets Framis sont alloués dynamiquement pour manquer de mémoire ; ceci teste le comportement au manque de mémoire. Puis un des objets est libéré, et un autre est créé pour montrer que la mémoire libérée est réutilisée.

Puisque ce schéma d'allocation est spécifique aux objets Framis, il est probablement beaucoup plus rapide que le schéma d'allocation générique utilisé pour les new et delete par défaut. Toutefois, vous devriez noter que cela ne marche pas automatiquement si l'héritage est utilisé (l'héritage est couvert au Chapitre 14).

13.5.3. Surcharger new & delete pour les tableaux

Si vous surchargez les opérateurs new et delete pour une classe, ces opérateurs sont appelés à chaque fois que vous créez un objet de cette classe. Toutefois, si vous créez un tableau d'objets de cette classe, l' operatornew( ) global est appelé pour allouer suffisamment d'espace de stockage pour tout le tableau d'un coup, et l' operator delete( ) global est appelé pour libérer ce stockage. Vous pouvez contrôler l'allocation de tableaux d'objets en surchargeant la version spéciale tableaux de operator new[ ] et operator delete[ ] pour la classe. Voici un exemple qui montre quand les deux versions différentes sont appelées :

 
Sélectionnez
//: C13:ArrayOperatorNew.cpp
// Operateur new pour tableaux
#include <new> // Définition de size_t
#include <fstream>
using namespace std;
ofstream trace("ArrayOperatorNew.out");
 
class Widget {
  enum { sz = 10 };
  int i[sz];
public:
  Widget() { trace << "*"; }
  ~Widget() { trace << "~"; }
  void* operator new(size_t sz) {
    trace << "Widget::new: "
         << sz << " octets" << endl;
    return ::new char[sz];
  }
  void operator delete(void* p) {
    trace << "Widget::delete" << endl;
    ::delete []p;
  }
  void* operator new[](size_t sz) {
    trace << "Widget::new[]: "
         << sz << " octets" << endl;
    return ::new char[sz];
  }
  void operator delete[](void* p) {
    trace << "Widget::delete[]" << endl;
    ::delete []p;
  }
};
 
int main() {
  trace << "new Widget" << endl;
  Widget* w = new Widget;
  trace << "\ndelete Widget" << endl;
  delete w;
  trace << "\nnew Widget[25]" << endl;
  Widget* wa = new Widget[25];
  trace << "\ndelete []Widget" << endl;
  delete []wa;
} ///:~

Ici, les versions globales de new et delete sont appelées si bien que l'effet est le même que de ne pas avoir de version surchargée de new et delete sauf qu'une information de traçage est ajoutée. Bien sûr, vous pouvez utliser n'importe quel schéma d'allocation de mémoire dans les new et delete surchargés.

Vous pouvez constater que la syntaxe de new et de delete pour tableaux est la même que pour leur version destinée aux objets individuels sauf qu'on y a ajouté des crochets. Dans les deux cas, on vous passe la taille de la mémoire que vous devez allouer. La taille passée à la version tableaux sera celle du tableau entier. Cela vaut la peine de garder à l'esprit que la seule chose que l' operator new( ) surchargé soit obligé de faire est de renvoyer un pointeur vers un bloc mémoire suffisamment grand. Bien que vous puissiez faire une initialisation de cette mémoire, c'est normalement le travail du contructeur qui sera automatiquement appelé pour votre mémoire par le compilateur.

Le constructeur et le destructeur affichent simplement des caractères afin que vous puissiez voir quand ils ont été appelés. Voici à quoi ressemble le fichier de traces pour un compilateur :

 
Sélectionnez
new Widget
Widget::new: 40 octets
*
delete Widget
~Widget::delete
 
new Widget[25]
Widget::new[]: 1004 octets
*************************
delete []Widget
~~~~~~~~~~~~~~~~~~~~~~~~~Widget::delete[]

Créer un objet individuel requiert 40 octet, comme vous pourriez prévevoir. (Cette machine utilise quatre octets pour un int.) L' operator new( ) est appelé, puis le constructeur (indiqué par *). De manière complémentaire, appeler delete entraîne l'appel du destructeur, puis de l' operator delete( ).

Comme promis, quand un tableau d'objets Widget est créé, la version tableau de l' operator new( ) est utilisée. Mais remarquez que la taille demandée dépasse de quatre octets la valeur attendue. Ces quatre octets supplémentaires sont l'endroit où le système conserve de l'information à propos du tableau, en particulier, le nombre d'objets dans le tableau. Ainsi, quand vous dites :

 
Sélectionnez
delete []Widget;

Les crochets disent au compilateur que c'est un tableau d'objets, afin que le compilateur génère le code pour chercher le nombre d'objets dans le tableau et appeler le destructeur autant de fois. Vous pouvez voir que même si les versions tableau de operator new( ) et operator delete( ) ne sont appelées qu'une fois pour tout le bloc du tableau, les constructeur et destructeur par défaut sont appelés pour chaque objet du tableau.

13.5.4. Appels au constructeur

En considérant que

 
Sélectionnez
MyType* f = new MyType;

appelle new pour allouer un espace de stockage de taille adaptée à un objet MyType, puis invoque le constructeur de MyType sur cet espace, que se passe-t-il si l'allocation de mémoire dans new échoue ? Dans ce cas, le constructeur n'est pas appelé, aussi, bien que vous ayez un objet créé sans succès, au moins vous n'avez pas appelé le contructeur et vous ne lui avez pas passé un poiteur this nul. Voici un exemple pour le prouver :

 
Sélectionnez
//: C13:NoMemory.cpp
// Le constructeur n'est pas appelé si new échoue
#include <iostream>
#include <new> // définition de bad_alloc 
using namespace std;
 
class NoMemory {
public:
  NoMemory() {
    cout << "NoMemory::NoMemory()" << endl;
  }
  void* operator new(size_t sz) throw(bad_alloc){
    cout << "NoMemory::operator new" << endl;
    throw bad_alloc(); // "Plus de memoire"
  }
};
 
int main() {
  NoMemory* nm = 0;
  try {
    nm = new NoMemory;
  } catch(bad_alloc) {
    cerr << "exception plus de mémoire" << endl;
  }
  cout << "nm = " << nm << endl;
} ///:~

Quand le programme s'exécute, il n'affiche pas le message du constructeur, uniquement le message de l' operator new( ) et le message dans le gestionnaire d'exception. Puisque new ne retourne jamais, le constructeur n'est pas appelé si bien que son message n'est pas affiché.

Il est important que nm soit initialisé à zéro parce que l'expression new n'aboutit jamais, et le pointeur devrait être à zéro pour être sûr que vous n'en ferez pas mauvais usage. Toutefois, vous devriez en fait faire plus de choses dans le gestionnaire d'exceptions que simplement afficher un message et continuer comme si l'objet avait été créé avec succès. Idéalement, vous ferez quelque chose qui permettra au programme de se remettre de ce problème, ou au moins de se terminer après avoir enregistré une erreur.

Dans les versions de C++ antérieures, c'était la pratique standard que new renvoie zéro si l'allocation du stockage échouait. Cela évitait que la construction se produise. Toutefois, si vous essayez de faire renvoyer zéro à new avec un compilateur conforme au standard, il devrait vous dire que vous êtes supposés lancer une excpetion bad_alloc à la place.

13.5.5. new & delete de placement

Il y a deux autres usages, moins courants, pour la surcharge de l' operator new( ).

  1. Vous pouvez avoir envie de placer un objet dans un emplacement spécifique de la mémoire. Ceci est particulièrement important pour les systèmes embarqués orientés matériel où un objet peut être synonyme d'un élément donnée du matériel.
  2. Vous pouvez vouloir être capable de choisir entre différents allocateurs quand vous appelez new.

Ces deux situations sont résolues par le même mécanisme : l' operator new( ) surchargé peut prendre plus d'un argument. Comme vous l'avez vu auparavant, le premier argument est toujours la taille de l'objet, qui est calculée en secret et passée par le compilateur. Mais les autres arguments peuvent être tout ce que vous voulez – l'adresse où vous voulez que l'objet soit placé, une référence vers une fonction ou un objet d'allocation de mémoire, ou quoique ce soit de pratique pour vous.

La façon dont vous passez les arguments supplémentaires à operator new( ) pendant un appel peut sembler un peu curieuse à première vue. Vous placez la liste d'arguments ( sans l'argument size_t, qui est géré par le compilateur) après le mot-clef new et avant le nom de la classe de l'objet que vous êtes en train de créer. Par exemple,

 
Sélectionnez
X* xp = new(a) X;

Passera a comme deuxième argument à operator new( ). Bien sûr, ceci ne peut fonctionner que si un tel operator new( ) a été déclaré.

Voic un exemple montrant comment vous pouvez placer un objet à un endroit donné :

 
Sélectionnez
//: C13:PlacementOperatorNew.cpp
// Placement avec l'opérateur new()
#include <cstddef> // Size_t
#include <iostream>
using namespace std;
 
class X {
  int i;
public:
  X(int ii = 0) : i(ii) {
    cout << "this = " << this << endl;
  }
  ~X() {
    cout << "X::~X(): " << this << endl;
  }
  void* operator new(size_t, void* loc) {
    return loc;
  }
};
 
int main() {
  int l[10];
  cout << "l = " << l << endl;
  X* xp = new(l) X(47); // X à l'emplacement l
  xp->X::~X(); // Appel explicite au destructeur
  // Utilisé UNIQUEMENT avec le placement !
} ///:~

Remarquez que operator new ne renvoie que le pointeur qui lui est passé. Ainsi, l'appelant décide où l'objet va résider, et le contructeur est appelé pour cette espace mémoire comme élément de l'expression new.

Bien que cet exemple ne montre qu'un argument additionnel, il n'y a rien qui vous empêche d'en ajouter davantage si vous en avez besoin pour d'autres buts.

Un dilemne apparaît lorsque vous voulez détruire l'objet. Il n'y a qu'une version de operator delete, et il n'y a donc pas moyen de dire, “Utilise mon dé-allocateur spécial pour cet objet.” Vous voulez appeler le destructeur, mais vous ne voulez pas que la mémoire soit libérée le mécanisme de mémoire dynamique parce qu'il n'a pas été alloué sur la pile.

La réponse est une syntaxe très spéciale. Vous pouvez explicitement appeler le destructeur, comme dans

 
Sélectionnez
xp->X::~X(); // Appel explicite au destructeur

Un avertissement sévère est justifié ici. Certaines personnes voient cela comme un moyen de détruire des objets à un certain moment avant la fin de la portée, plutôt que soit d'ajuster la portée ou (manière plus correcte de procéder) en utilisant la création dynamique d'objets s'ils veulent que la durée de vie de l'objet soit déterminée à l'exécution. Vous aurez de sérieux problèmes si vous appelez le destructeur ainsi pour un objet ordinaire créé sur la pile parce que le destructeur sera appelé à nouveau à la fin de la portée. Si vous appelez le destrcuteur ainsi pour un objet qui a été créé sur le tas, le destructeur s'exécutera, mais la mémoire ne sera pas libérée, ce qui n'est probablement pas ce que vous désirez. La seule raison pour laquelle le destructeur peut être appelé explicitement ainsi est pour soutenir la syntaxe de placement de operator new.

Il y a également un operator delete de placement qui est appelé uniquement si un constructeur pour une expression de placement new lance une exception (afin que la mémoire soit automatiquement nettoyée pendant l'exception). L' operator delete de placement a une liste d'arguments qui correspond à l' operator new de placement qui est appelé avant que le constucteur ne lance l'exception. Ce sujet sera couvert dans le chapitre de gestion des exceptions dans le Volume 2.

13.6. Résumé

Il est commode et optimal du point de vue de l'efficacité de créer des objets sur la pile, mais pour résoudre le problème de programmation général vous devez être capables de créer et de détruire des objets à tout moment durant l'exécution d'un programme, spécialement pour réagir à des informations provenant de l'extérieur du programme. Bien que l'allocation dynamique de mémoire du C obtienne du stockage sur le tas, cela ne fournit pas la facilité d'utilisation ni la garantie de construction nécessaire en C++. En portant la création dynamique d'objets au coeur du langage avec new et delete, vous pouvez créer des objets sur le tas aussi facilement que sur la pile. En outre, vous profitez d'une grande flexibilité. Vous pouvez modifier le comportement de new et delete s'ils ne correspondent pas à vos besoins, particulièrement s'ils ne sont pas suffisamment efficaces. Vous pouvez aussi modifier ce qui se produit quand l'espace de stockage du tas s'épuise.

13.7. Exercices

Les solutions aux exercices choisis peuvent être trouvées dans le document électronique The Thinking in C++ Annotated Solution Guide, disponible à un coût modeste sur www.BruceEckel.com.

  1. Créez une classe Counted qui contient un int id et un static int count. Le constructeur par défaut devrait commencer ainsi : Counted( ) : id(count++) {. Il devrait aussi afficher son id et qu'il est en train d'être créé. Le destructeur devrait afficher qu'il est en train d'être détruit et son id. Testez votre classe.
  2. Prouvez vous à vous-même que new et delete appellent toujours les constructeurs et les destructeurs en créant un objet de la class Counted(de l'exercice 1) avec new et en le détruisant avec delete. Créez et détruisez également un tableau de ces objets sur le tas.
  3. Créez un objet PStash et remplissez le avec de nouveaux objets de l'exercice 1. Observez ce qui se passe quand cet objet PStash disparaît de la portée et que son destructeur est appelé.
  4. Créez un vector<Counted*> et remplissez le avec des pointeurs vers des nouveaux objets Counted(de l'exercice 1). Parcourez le vector et affichez les objets Counted, ensuite parcourez à nouveau le vector et detruisez chacun d'eux.
  5. Répétez l'exercice 4, mais ajoutez une fonction membre f( ) à Counted qui affiche un message. Parcourez le vector et appelez f( ) pour chaque objet.
  6. Répétez l'exercice 5 en utilisant un PStash.
  7. Répétez l'exercice 5 en utilisant Stack4.h du chapitre 9.
  8. Créez dynamiquement un tableau d'objets class Counted(de l'exercice 1). Appelez delete pour le pointeur résultant, sans les crochets. Expliquez les résultats.
  9. Créez un objet de class Counted(de l'exercice 1) en utilisant new, transtypez le pointeur résultant en un void*, et détruisez celui là. Expliquez les résultats.
  10. Executez NewHandler.cpp sur votre machine pour voir le compte résultant. Calculez le montant d'espace libre (free store ndt) disponible pourvotre programme.
  11. Créez une classe avec des opérateurs new et delete surchargés, à la fois les versions 'objet-unique' et les versions 'tableau'. Démontrez que les deux versions fonctionnent.
  12. Concevez un test pour Framis.cpp pour vous montrer approximativement à quel point les versions personnalisées de new et delete fonctionnent plus vite que les new et delete globaux.
  13. Modifiez NoMemory.cpp de façon qu'il contienne un tableau de int et de façon qu'il alloue effectivement de la mémoire au lieu de lever l'exception bad_alloc. Dans main( ), mettez en place une boucle while comme celle dans NewHandler.cpp pour provoquer un épuisement de la mémoire and voyez ce qui se passe si votre operator new ne teste pas pour voir si la mémoire est allouée avec succès. Ajoutez ensuite la vérification à votre operator new et jetez bad_alloc.
  14. Créez une classe avec un new de placement avec un second argument de type string. La classe devrait contenir un static vector<string> où le second argument de new est stocké. The new de placement devrait allouer de l'espace comme d'habitude. Dans main( ), faites des appels à votre new de placement avec des arguments string qui décrivent les appels (Vous pouvez vouloir utiliser les macros du préprocesseur __FILE__ et __LINE__).
  15. Modifiez ArrayOperatorNew.cpp en ajoutant un static vector<Widget*> qui ajoute chaque adresse Widget qui est allouée dans operator new( ) et l'enlève quand il est libéré via l' operator delete( ). (Vous pourriez avoir besoin de chercher de l'information sur vector dans la documentation de votre Librairie Standard C++ ou dans le second volume de ce livre, disponible sur le site Web.) Créez une seconde classe appelée MemoryChecker ayant un destructeur qui affiche le nombre de pointeurs Widget dans votre vector. Créez un programme avec une unique instance globale de MemoryChecker et dans main( ), allouez dynamiquement et détruisez plusieurs objets et tableaux de Widget. Montrez que MemoryChecker révèle des fuites de mémoire.

précédentsommairesuivant
Il y a une syntaxe spéciale appelée placement new qui vous permet d'appeler un constructeur pour une zone de mémoire pré-allouée. Ceci est introduit plus tard dans le chapitre.

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2005 Bruce Eckel. Aucune reproduction, même partielle, ne peut être faite de ce site ni 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.