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

Penser en C++

Volume 1


précédentsommairesuivant

VII. Initialisation et Nettoyage

Le chapitre 4 a apporté une amélioration significative dans l'utilisation d'une bibliothèque en prenant tous les composants dispersés d'une bibliothèque typique du C et en les encapsulant dans une structure (un type de données abstrait, appelé dorénavant une classe).

Ceci fournit non seulement un point d'entrée unique dans un composant de bibliothèque, mais cela cache également les noms des fonctions dans le nom de classe. Dans le chapitre 5, le contrôle d'accès (le masquage de l'implémentation) a été présenté. Ceci donne au concepteur de classe une manière d'établir des frontières claires pour déterminer ce que le programmeur client a la permission de manœuvrer et ce qui hors des limites. Cela signifie que les mécanismes internes d'une opération d'un type de données sont sous le contrôle et la discrétion du concepteur de la classe, et il est clair pour les programmeurs de client à quels membres ils peuvent et devraient prêter attention.

Ensemble, l'encapsulation et le contrôle d'accès permettent de franchir une étape significative en améliorant la facilité de l'utilisation de la bibliothèque. Le concept du “nouveau type de données” qu'ils fournissent est meilleur par certains côtés que les types de données intégrés existants du C. Le compilateur C++ peut maintenant fournir des garanties de vérification de type pour ce type de données et assurer ainsi un niveau de sûreté quand ce type de données est employé.

Cependant quand il est question de sécurité, le compilateur peut en faire beaucoup plus pour nous que ce qui est proposé par le langage C. Dans ce chapitre et de futurs, vous verrez les dispositifs additionnels qui ont été mis en œuvre en C++ qui font que les bogues dans votre programme vous sautent presque aux yeux et vous interpellent, parfois avant même que vous ne compiliez le programme, mais habituellement sous forme d'avertissements et d'erreurs de compilation. Pour cette raison, vous vous habituerez bientôt au scénario inhabituel d'un programme C++ qui compile fonctionne du premier coup.

Deux de ces questions de sûreté sont l'initialisation et le nettoyage. Une grande partie des bogues C se produisent quand le programmeur oublie d'initialiser ou de vider une variable. C'est particulièrement vrai avec des bibliothèques C, quand les programmeurs de client ne savent pas initialiser une structure, ou même ce qu'ils doivent initialiser. (Les bibliothèques n'incluent souvent pas de fonction d'initialisation, et le programmeur client est forcé d'initialiser la structure à la main.) Le nettoyage est un problème spécial parce que les programmeurs en langage C oublient facilement les variables une fois qu'elles ne servent plus, raison pour laquelle le nettoyage qui peut être nécessaire pour une structure d'une bibliothèque est souvent oublié.

En C++, le concept d'initialisation et de nettoyage est essentiel pour une utilisation facile d'une bibliothèque et pour éliminer les nombreux bogues subtils qui se produisent quand le programmeur de client oublie d'exécuter ces actions. Ce chapitre examine les dispositifs C++ qui aident à garantir l'initialisation appropriée et le nettoyage.

VII-A. Initialisation garantie avec le constructeur

Les deux classes Stash/ cachette et Stack/pile définies plus tôt ont une fonction appelée initialisation( ), qui indique par son nom qu'elle doit être appelée avant d'utiliser l'objet de quelque manière que ce soit. Malheureusement, ceci signifie que le client programmeur doit assurer l'initialisation appropriée. Les clients programmeurs sont enclins à manquer des détails comme l'initialisation dans la précipitation pour utiliser votre incroyable librairie pour résoudre leurs problèmes. En C++, l'initialisation est trop importante pour la laisser au client programmeur. Le concepteur de la classe peut garantir l'initialisation de chaque objet en fournissant une fonction spéciale appelée constructeur. Si une classe a un constructeur, le compilateur appellera automatiquement ce constructeur au moment où l'objet est créé, avant que le programmeur client puisse toucher à l'objet. Le constructeur appelé n'est pas une option pour le client programmeur ; il est exécuté par le compilateur au moment où l'objet est défini.

Le prochain défi est de donner un nom à cette fonction. Il y a deux problèmes. La première est que le nom que vous utilisez peut potentiellement être en conflit avec un nom que vous pouvez utiliser pour un membre de la classe. Le second est que comme le compilateur est responsable de l'appel au constructeur, il doit toujours savoir quelle fonction appeler. La solution que Stroustrup a choisie semble la plus simple et la plus logique : le nom du constructeur est le même que le nom de la classe. Cela semble raisonnable qu'une telle fonction puisse être appelée automatiquement à l'initialisation.

Voici une classe simple avec un constructeur:

 
Sélectionnez
class X {
  int i;
public:
  X();  // Constructeur
};

Maintenant, quand un objet est défini,

 
Sélectionnez
void f() {
  X a;
  // ...
}

la même chose se produit que si a était un int: le stockage est alloué pour l'objet. Mais quand le programme atteint le point de séquence (point d'exécution) où a est définie, le constructeur est appelé automatiquement. C'est le compilateur qui insère discrètement l'appel à X::X( ) pour l'objet a au point de définition. Comme n'importe quelle fonction membre, le premier argument (secret) pour le constructeur est le pointeur this- l'adresse de l'objet pour lequel il est appelé. Dans le cas d'un constructeur, cependant, this pointe sur un bloc non initialisé de mémoire, et c'est le travail du constructeur d'initialiser proprement cette mémoire.

Comme n'importe quelle fonction, le constructeur peut avoir des arguments pour vous permettre d'indiquer comment un objet est créé, lui donner des valeurs d'initialisation, et ainsi de suite. Les arguments du constructeur vous donnent une manière de garantir que toutes les parties de votre objet sont initialisées avec des valeurs appropriées. Par exemple, si une classe Arbre a un constructeur qui prend un seul entier en argument donnant la hauteur de l'arbre, vous devez créer un objet arbre comme cela:

 
Sélectionnez
Arbre a(12);  // arbre de 12 pieds (3,6 m)

Si Arbre(int) est votre seul constructeur, le compilateur ne vous laissera pas créer un objet d'une autre manière. (Nous allons voir les constructeurs multiples et les différentes possibilités pour appeler les constructeurs dans le prochain chapitre.)

Voici tout ce que fait un constructeur ; c'est une fonction avec un nom spécial qui est appelé automatiquement par le compilateur pour chaque objet au moment de sa création. En dépit de sa simplicité, c'est très précieux parce qu'il élimine une grande classe de problèmes et facilite l'écriture et la lecture du code. Dans le fragment de code ci-dessus, par exemple vous ne voyez pas un appel explicite à une quelconque fonction initialisation( ) qui serait conceptuellement différente de la définition. En C++, définition et initialisation sont des concepts unifiés, vous ne pouvez pas avoir l'un sans l'autre.

Le constructeur et le destructeur sont des types de fonctions très peu communes : elles n'ont pas de valeur de retour. C'est clairement différent d'une valeur de retour void, où la fonction ne retourne rien, mais où vous avez toujours l'option de faire quelque chose d'autre. Les constructeurs et destructeurs ne retournent rien et vous ne pouvez rien y changer. L'acte de créer ou détruire un objet dans le programme est spécial, comme la naissance et la mort, et le compilateur fait toujours les appels aux fonctions par lui-même, pour être sûr qu'ils ont lieu. S’il y avait une valeur de retour, et si vous pouviez sélectionner la vôtre, le compilateur devrait d'une façon ou d'une autre savoir que faire avec la valeur de retour, ou bien le client programmeur devrait appeler explicitement le constructeur et le destructeur, ce qui détruirait leurs sécurité.

VII-B. Garantir le nettoyage avec le destructeur

En tant que programmeur C++, vous pensez souvent à l'importance de l'initialisation, mais il est plus rare que vous pensiez au nettoyage. Après tout, que devez-vous faire pour nettoyer un int ? Seulement l'oublier. Cependant, avec des bibliothèques, délaisser tout bonnement un objet lorsque vous en avez fini avec lui n'est pas aussi sûr. Que se passe-t-il s'il modifie quelque chose dans le hardware, ou affiche quelque chose à l'écran, ou alloue de la mémoire sur le tas/pile ? Si vous vous contentez de l'oublier, votre objet n'accomplit jamais sa fermeture avant de quitter ce monde. En C++, le nettoyage est aussi important que l'initialisation et est donc garanti par le destructeur.

La syntaxe du destructeur est semblable à celle du constructeur : le nom de la classe est employé pour le nom de la fonction. Cependant, le destructeur est distingué du constructeur par un tilde ( ~) en préfixe. En outre, le destructeur n'a jamais aucun argument parce que la destruction n'a jamais besoin d'aucune option. Voici la déclaration pour un destructeur :

 
Sélectionnez
class Y {
public:
  ~Y();
};

Le destructeur est appelé automatiquement par le compilateur quand l'objet sort de la portée. Vous pouvez voir où le constructeur est appelé lors de la définition de l'objet, mais la seule preuve d'un appel au destructeur est l'accolade de fermeture de la portée qui entoure l'objet. Pourtant le destructeur est toujours appelé, même lorsque vous employez goto pour sauter d'une portée. ( goto existe en C++ pour la compatibilité ascendante avec le C et pour les fois où il est pratique.) Notez qu'un goto non local, implémenté par les fonctions de la bibliothèque standard du C setjmp( ) et longjmp( ), ne provoque pas l'appel des destructeurs. (Ce sont les spécifications, même si votre compilateur ne les met pas en application de cette façon. Compter sur un dispositif qui n'est pas dans les spécifications signifie que votre code est non portable.)

Voici un exemple illustrant les dispositifs des constructeurs et destructeurs que vous avez vu jusqu'à maintenant :

 
Sélectionnez
//: C06:Constructor1.cpp
// Construteurs & destructeurs
#include <iostream>
using namespace std;
 
class Tree {
  int height;
public:
  Tree(int initialHeight);  // Constructeur
  ~Tree();  // Destructeur
  void grow(int years);
  void printsize();
};
 
Tree::Tree(int initialHeight) {
  height = initialHeight;
}
 
Tree::~Tree() {
  cout << "au cœur du destructeur de Tree" << endl;
  printsize();
}
 
void Tree::grow(int years) {
  height += years;
}
 
void Tree::printsize() {
  cout << "La taille du Tree est " << height << endl;
}
 
int main() {
  cout << "avant l'ouverture de l'accolade" << endl;
  {
    Tree t(12);
    cout << "après la création du Tree" << endl;
    t.printsize();
    t.grow(4);
    cout << "avant la fermeture de l'accolade" << endl;
  }
  cout << "après la fermeture de l'accolade" << endl;
} ///:~

Voici la sortie du programme précédent :

 
Sélectionnez
avant l'ouverture de l'accolade
après la création du Tree
La taille du Tree est 12
avant la fermeture de l'accolade
au cœur du destructeur de Tree
La taille du Tree est 16
après la fermeture de l'accolade

Vous pouvez voir que le destructeur est automatiquement appelé à la fermeture de la portée qui entoure l'objet.

VII-C. Élimination de la définition de bloc

En C, vous devez toujours définir toutes les variables au début d'un bloc, après l'ouverture des accolades. Ce n'est pas une exigence inhabituelle dans les langages de programmation, et la raison donnée a souvent été que c'est un “bon style de programmation ». Sur ce point, j'ai des doutes. Il m'a toujours semblé malcommode, comme programmeur, de retourner au début d'un bloc à chaque fois que j'ai besoin d'une nouvelle variable. Je trouve aussi le code plus lisible quand la déclaration d'une variable est proche de son point d'utilisation.

Peut-être ces arguments sont-ils stylistiques ? En C++, cependant, il y a un vrai problème à être forcé de définir tous les objets au début de la portée. Si un constructeur existe, il doit être appelé quand l'objet est créé. Cependant, si le constructeur prend un ou plusieurs arguments d'initialisation, comment savez-vous que vous connaitrez cette information d'initialisation au début de la portée? En générale, vous ne la connaitrez pas. Parce que le C n'a pas de concept de private, cette séparation de définition et d'initialisation n'est pas problématique. Cependant, le C++ garantit que quand un objet est créé, il est simultanément initialisé. Ceci garantit que vous n'aurez pas d'objet non initialisé se promenant dans votre système. Le C n'en a rien à faire ; en fait, le C encourage cette pratique en requérant que vous définissiez les variables au début du bloc avant que vous ayez nécessairement l'information d'initialisation (37).

En général, le C++ ne vous permettra pas de créer un objet avant que vous ayez les informations d'initialisation pour le constructeur. À cause de cela, le langage ne fonctionnerait pas si vous aviez à définir les variables au début de la portée. En fait, le style du langage semble encourager la définition d'un objet aussi près de son utilisation que possible. En C++, toute règle qui s'applique à un “objet” s'applique également automatiquement à un objet de type intégré. Ceci signifie que toute classe d'objet ou variable d'un type intégré peut aussi être définie à n'importe quel point de la portée. Ceci signifie que vous pouvez attendre jusqu'à ce que vous ayez l'information pour une variable avant de la définir, donc vous pouvez toujours définir et initialiser au même moment:

 
Sélectionnez
//: C06:DefineInitialize.cpp
// Définir les variables n'importe où
#include "../require.h"
#include <iostream>
#include <string>
using namespace std;
 
class G {
  int i;
public:
  G(int ii);
};
 
G::G(int ii) { i = ii; }
 
int main() {
  cout << "valeur d'initialisation? ";
  int retval = 0;
  cin >> retval;
  require(retval != 0);
  int y = retval + 3;
  G g(y);
} ///:~

Vous constatez que du code est exécuté, puis retval est définie, initialisé, et utilisé pour récupérer l'entrée de l'utilisateur, et puis y et g sont définies. C, par contre, ne permet pas à une variable d'être définie n'importe où excepté au début de la portée.

En général, vous devriez définir les variables aussi près que possible de leur point d'utilisation, et toujours les initialiser quand elles sont définies. (Ceci est une suggestion de style pour les types intégrés, pour lesquels l'initialisation est optionnelle). C'est une question de sécurité. En réduisant la durée de disponibilité d'une variable dans la portée, vous réduisez les chances d'abus dans une autre partie de la portée. En outre, la lisibilité est augmentée parce que le lecteur n'a pas besoin de faire des allées et venues au début de la portée pour connaitre le type d'une variable.

VII-C-1. les boucles

En C++, vous verrez souvent un compteur de boucle for défini directement dans l'expression for:

 
Sélectionnez
for(int j = 0; j < 100; j++) {
    cout << "j = " << j << endl;
}
for(int i = 0; i < 100; i++)
 cout << "i = " << i << endl;

Les déclarations ci-dessus sont des cas spéciaux importants, qui embarrassent les nouveaux programmeurs en C++.

Les variables i et j sont définies directement dans l'expression for (ce que vous ne pouvez pas faire en C). Elles peuvent être utilisées dans la boucle for. C'est une syntaxe vraiment commode parce que le contexte répond à toute question concernant le but de i et j, donc vous n'avez pas besoin d'utiliser des noms malcommodes comme i_boucle_compteur pour plus de clarté.

Cependant, vous pouvez vous tromper si vous supposez que la durée de vie des variables i et j se prolonge au-delà de la portée de la boucle for – ce n'est pas le cas (38).

Le chapitre 3 souligne que les déclarations while et switch permettent également la définition des objets dans leurs expressions de contrôle, bien que cet emploi semble beaucoup moins important que pour les boucles for.

Méfiez-vous des variables locales qui cachent des variables de la portée englobant la boucle. En général, utiliser le même nom pour une variable imbriquée et une variable globale est confus et enclin à l'erreur (39).

Je considère les petites portées comme des indicateurs de bonne conception. Si une simple fontion fait plusieurs pages, peut être que vous essayez de faire trop de choses avec cette fonction. Des fonctions plus granulaires sont non seulement plus utiles, mais permettent aussi de trouver plus facilement les bugs.

VII-C-2. Allocation de mémoire

Une variable peut maintenant être définie à n'importe quel point de la portée, donc il pourrait sembler que le stockage pour cette variable ne peut pas être défini jusqu'à son point de déclaration. Il est en fait plus probable que le compilateur suivra la pratique du C d'allouer tous les stockages d'une portée à l'ouverture de celle-ci. Ce n'est pas important, parce que, comme programmeur, vous ne pouvez pas accéder aux stockages (ou l'objet) jusqu'à ce qu'il ait été défini (40). Bien que le stockage soit alloué au commencement d'un bloc, l'appel au constructeur n'a pas lieu avant le point de séquence où l'objet est défini parce que l'identifiant n'est pas disponible avant cela. Le compilateur vérifie même que vous ne mettez pas la définition de l'objet (et ainsi l'appel du constructeur) là où le point de séquence passe seulement sous certaines conditions, comme dans un switch ou à un endroit qu'un goto peut sauter. Ne pas commenter les déclarations dans le code suivant produira des warnings ou des erreurs :

 
Sélectionnez
//: C06:Nojump.cpp
// ne peut pas sauter le constructeur
 
class X {
public:
  X();
};
 
X::X() {}
 
void f(int i) {
  if(i < 10) {
   //! goto jump1; // Erreur: goto outrepasse l'initialisation
  }
  X x1;  // Constructeur appelé ici
 jump1:
  switch(i) {
    case 1 :
      X x2;  // Constructeur appelé ici
      break;
  //! case 2 : // Erreur: goto outrepasse l'initialisation
      X x3;  // Constructeur appelé ici
      break;
  }
} 
 
int main() {
  f(9);
  f(11);
}///:~

Dans le code ci-dessus, le goto et le switch peuvent tous deux sauter le point de séquence où un constructeur est appelé. Cet objet sera alors dans la portée même sans que le constructeur ait été appelé, donc le compilateur génère un message d'erreur. Ceci garantit encore une fois qu'un objet ne soit pas créé sans être également initialisé.

Toutes les allocations mémoires discutées ici, se produisent, bien sûr, sur la pile. Le stockage est alloué par le compilateur en déplaçant le pointeur de pile vers le “bas” (un terme relatif, ce qui peut indiquer une augmentation ou une diminution de la valeur réelle du pointeur de pile, selon votre machine). Les objets peuvent aussi être alloués sur la pile en utilisant new, ce que nous explorerons dans le chapitre 13.

VII-D. Stash avec constructeur et destructeur

Les exemples des chapitres précédents ont des fonctions évidentes qui établissent les constructeurs et destructeurs : initialize( ) et cleanup( ). Voici l'en-tête Stash utilisant les constructeurs et destructeurs :

 
Sélectionnez
//: C06:Stash2.h
// Avec constructeurs & destructeurs
#ifndef STASH2_H
#define STASH2_H
 
class Stash {
  int size;      // Taille de chaque espace
  int quantity;  // Nombre d'espaces de stockage
  int next;      // Espace vide suivant
  // Tableau d'octet alloué dynamiquement
  unsigned char* storage;
  void inflate(int increase);
public:
  Stash(int size);
  ~Stash();
  int add(void* element);
  void* fetch(int index);
  int count();
};
#endif // STASH2_H ///:~

Les seules définitions de fonctions qui changent sont initialize( ) et cleanup( ), qui ont été remplacées par un constructeur et un destructeur :

 
Sélectionnez
//: C06:Stash2.cpp {O}
// Constructeurs & destructeurs
#include "Stash2.h"
#include "../require.h"
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
 
Stash::Stash(int sz) {
  size = sz;
  quantity = 0;
  storage = 0;
  next = 0;
}
 
int Stash::add(void* element) {
  if(next >= quantity) // Reste-t-il suffisamment de place ?
    inflate(increment);
  // Copier l'élément dans l'espce de stockage,
  // à partir de l'espace vide suivant :
  int startBytes = next * size;
  unsigned char* e = (unsigned char*)element;
  for(int i = 0; i < size; i++)
    storage[startBytes + i] = e[i];
  next++;
  return(next - 1); // Nombre indice
}
 
void* Stash::fetch(int index) {
  require(0 <= index, "Stash::fetch (-)index");
  if(index >= next)
    return 0; // Pour indiquer la fin
  // Produire un pointeur vers l'élément voulu :
  return &(storage[index * size]);
}
 
int Stash::count() {
  return next; // Nombre d'éléments dans CStash
}
 
void Stash::inflate(int increase) {
  require(increase > 0, 
    "Stash::inflate zero or negative increase");
  int newQuantity = quantity + increase;
  int newBytes = newQuantity * size;
  int oldBytes = quantity * size;
  unsigned char* b = new unsigned char[newBytes];
  for(int i = 0; i < oldBytes; i++)
    b[i] = storage[i]; // Copie vieux dans nouveau
  delete [](storage); // Vieux stockage
  storage = b; // Pointe vers la nouvelle mémoire
  quantity = newQuantity;
}
 
Stash::~Stash() {
  if(storage != 0) {
   cout << "freeing storage" << endl;
   delete []storage;
  }
} ///:~

Vous pouvez constater que les fonctions require.h sont utilisées pour surveiller les erreurs du programmeur, à la place de assert( ). La sortie d'un échec de assert( ) n'est pas aussi utile que celle des fonctions require.h(qui seront présentées plus loin dans ce livre).

Comme inflate( ) est privé, la seule façon pour qu'un require( ) puisse échouer est si une des autres fonctions membres passe accidentellement une valeur erronée à inflate( ). Si vous êtes sûr que cela ne peut arriver, vous pouvez envisager d'enlever le require( ), mais vous devriez garder cela en mémoire jusqu'à ce que la classe soit stable ; il y a toujours la possibilité que du nouveau code soit ajouté à la classe, qui puisse causer des erreurs. Le cout du require( ) est faible (et pourrait être automatiquement supprimé en utilisant le préprocesseur) et la valeur en terme de robustesse du code est élevée.

Notez dans le programme test suivant comment les définitions pour les objets Stash apparaissent juste avant qu'elles soient nécessaires, et comment l'initialisation apparaît comme une part de la définition, dans la liste des arguments du constructeur :

 
Sélectionnez
//: C06:Stash2Test.cpp
//{L} Stash2
// Constructeurs & destructeurs
#include "Stash2.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main() {
  Stash intStash(sizeof(int));
  for(int i = 0; i < 100; i++)
    intStash.add(&i);
  for(int j = 0; j < intStash.count(); j++)
    cout << "intStash.fetch(" << j << ") = "
         << *(int*)intStash.fetch(j)
         << endl;
  const int bufsize = 80;
  Stash stringStash(sizeof(char) * bufsize);
  ifstream in("Stash2Test.cpp");
  assure(in, " Stash2Test.cpp");
  string line;
  while(getline(in, line))
    stringStash.add((char*)line.c_str());
  int k = 0;
  char* cp;
  while((cp = (char*)stringStash.fetch(k++))!=0)
    cout << "stringStash.fetch(" << k << ") = "
         << cp << endl;
} ///:~

Notez également comment les appels à cleanup( ) ont été éliminés, mais les destructeurs sont toujours appelés automatiquement quand intStash et stringStash sortent du champ.

Une chose dont il faut être conscient dans les exemples de Stash: je fais très attention d'utiliser uniquement des types intégrés ; c'est-à-dire ceux sans destructeurs. Si vous essayiez de copier des classes d'objets dans le Stash, vous rencontreriez beaucoup de problèmes et cela ne fonctionnerait pas correctement. La librairie standard du C++ peut réellement faire des copies d'objets correctes dans ses conteneurs, mais c'est un processus relativement sale et complexe. Dans l'exemple Stack suivant, vous verrez que les pointeurs sont utilisés pour éviter ce problème, et dans un prochain chapitre le Stash sera modifié afin qu'il utilise des pointeurs.

VII-E. Stack avec des constructeurs & des destructeurs

Réimplémenter la liste chaînée (dans Stack) avec des constructeurs et des destructeurs montrent comme les constructeurs et les destructeurs marchent proprement avec new et delete. Voici le fichier d'en-tête modifié :

 
Sélectionnez
//: C06:Stack3.h
// Avec constructeurs/destructeurs
#ifndef STACK3_H
#define STACK3_H
 
class Stack {
  struct Link {
    void* data;
    Link* next;
    Link(void* dat, Link* nxt);
    ~Link();
  }* head;
public:
  Stack();
  ~Stack();
  void push(void* dat);
  void* peek();
  void* pop();
};
#endif // STACK3_H ///:~

Il n'y a pas que Stack qui ait un constructeur et un destructeur, mais la structLink imbriquée également :

 
Sélectionnez
//: C06:Stack3.cpp {O}
// Constructeurs/destructeurs
#include "Stack3.h"
#include "../require.h"
using namespace std;
 
Stack::Link::Link(void* dat, Link* nxt) {
  data = dat;
  next = nxt;
}
 
Stack::Link::~Link() { }
 
Stack::Stack() { head = 0; }
 
void Stack::push(void* dat) {
  head = new Link(dat,head);
}
 
void* Stack::peek() { 
  require(head != 0, "Stack vide");
  return head->data; 
}
 
void* Stack::pop() {
  if(head == 0) return 0;
  void* result = head->data;
  Link* oldHead = head;
  head = head->next;
  delete oldHead;
  return result;
}
 
Stack::~Stack() {
  require(head == 0, "Stack non vide");
} ///:~

Le constructeur Link::Link( ) initialise simplement les pointeurs data et next, ainsi dans Stack::push( ) la ligne

 
Sélectionnez
head = new Link(dat,head);

ne fait pas qu'allouer un nouveau Link(en utilisant la création dynamique d'objets avec le mot-clé new, introduit au Chapitre 4), mais initialise également proprement les pointeurs pour ce Link.

Vous pouvez vous demander pourquoi le destructeur de Link ne fait rien – en particulier, pourquoi ne supprime-t-il pas le pointeur data? Il y a deux problèmes. Au chapitre 4, où la Stack a été présentée, on a précisé que vous ne pouvez pas correctement supprimer un pointeur void s'il pointe un objet (une affirmation qui sera prouvée au Chapitre 13). En outre, si le destructeur de Link supprimait le pointeur data, pop( ) finirait par retourner un pointeur sur un objet supprimé, ce qui serait certainement un bogue. Ce problème est désigné parfois comme la question de la propriété: Link, et par là Stack, utilise seulement les pointeurs, mais n'est pas responsable de leur libération. Ceci signifie que vous devez faire très attention de savoir qui est responsable. Par exemple, si vous ne dépilez et ne supprimez pas tous les pointeurs de la Stack, ils ne seront pas libérés automatiquement par le destructeur de Stack. Ceci peut être un problème récurrent et mène aux fuites de mémoire, ainsi savoir qui est responsable la destruction d'un objet peut faire la différence entre un programme réussi et un bogué – C'est pourquoi Stack::~Stack( ) affiche un message d'erreur si l'objet Stack n'est pas vide lors de la destruction.

Puisque l'allocation et la désallocation des objets Link sont cachés dans la Stack– cela fait partie de l'implémentation interne – vous ne la voyez pas se produire dans le programme de test, bien que vous soyez responsable de supprimer les pointeurs qui arrivent de pop( ):

 
Sélectionnez
//: C06:Stack3Test.cpp
//{L} Stack3
//{T} Stack3Test.cpp
// Constructeurs/destructeurs
#include "Stack3.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main(int argc, char* argv[]) {
  requireArgs(argc, 1); // Le nom de fichier est un argument
  ifstream in(argv[1]);
  assure(in, argv[1]);
  Stack textlines;
  string line;
  // Lectrure du fichier et stockage des lignes dans la pile :
  while(getline(in, line))
    textlines.push(new string(line));
  // Dépiler les lignes de la pile et les afficher :
  string* s;
  while((s = (string*)textlines.pop()) != 0) {
    cout << *s << endl;
    delete s; 
  }
} ///:~

Dans ce cas-là, toutes les lignes dans les textlines sont dépilées et supprimées, mais si elles ne l'étaient pas, vous recevriez un message require( ) qui signifie qu'il y a une fuite de mémoire.

VII-F. Initialisation d'agrégat

Un agrégat est exactement ce qu'il a l'air d'être : un paquet de choses rassemblées. Cette définition inclut des agrégats de type mixte, comme les structures et les class es. Un tableau est un agrégat d'un type unique.

Initialiser les agrégats peut être enclin à l'erreur et fastidieux. L' initialisation d'agrégat du C++ rend l'opération beaucoup plus sûre. Quand vous créez un objet qui est un agrégat, tout ce que vous avez à faire est de faire une déclaration, et l'initialisation sera prise en charge par le compilateur. Cette déclaration peut avoir plusieurs nuances, selon le type d'agrégat auquel vous avez affaire, mais dans tous les cas les éléments de la déclaration doivent être entourés par des accolades. Pour un tableau de types intégrés, c'est relativement simple :

 
Sélectionnez
int a[5] = { 1, 2, 3, 4, 5 };

Si vous essayez de passer plus de valeurs qu'il n'y a d'éléments dans le tableau, le compilateur génère un message d'erreur. Mais que se passe-t-il si vous passez moins de valeurs ? Par exemple :

 
Sélectionnez
int b[6] = {0};

Ici, le compilateur utilisera la première valeur pour le premier élément du tableau, et ensuite utilisera zéro pour tous les éléments sans valeur fournie. Remarquez que ce comportement ne se produit pas si vous définissez un tableau sans liste de valeurs. Donc, l'expression ci-dessus est un moyen succint d'initialiser un tableau de zéros, sans utiliser une boucle for, et sans possibilité d'erreur de bord (selon les compilateurs, ce procédé peut même être plus efficace que la boucle for).

Un deuxième raccourci pour les tableaux est le comptage automatique, dans lequel vous laissez le compilateur déterminer la taille d'un tableau à partir du nombre de valeurs passées à l'initialisation :

 
Sélectionnez
int c[] = { 1, 2, 3, 4 };

A présent, si vous décidez d'ajouter un nouvel élément au tableau, vous ajoutez simplement une autre valeur initiale. Si vous pouvez concevoir vote code de façon à ce qu'il n'ait besoin d'être modifié qu'en un seul endroit, vous réduisez les risques d'erreur liés à la modification. Mais comment déterminez-vous la taille du tableau ? L'expression sizeof c / sizeof *c(taille totale du tableau divisée par la taille du premier élément) fait l'affaire sans avoir besoin d'être modifié si la taille du tableau varie. (41):

 
Sélectionnez
for(int i = 0; i < sizeof c / sizeof *c; i++)
 c[i]++;

Comme les structures sont également des agrégats, elles peuvent être initialisées de façon similaire. Comme les membres d'un struct type C sont tous public, ils peuvent être initialisés directement :

 
Sélectionnez
struct X {
  int i;
  float f;
  char c;
};
 
X x1 = { 1, 2.2, 'c' };

Si vous avez un tableau d'objets de ce genre, vous pouvez les initialiser en utilisant un ensemble d'accolades imbriquées pour chaque objet :

 
Sélectionnez
X x2[3] = { {1, 1.1, 'a'}, {2, 2.2, 'b'} };

Ici, le troisième objet est initialisé à zéro.

Si n'importe laquelle des données membres est private(ce qui est typiquement le cas pour une classe C++ correctement conçue), ou même si tout est public, mais qu'il y a un entrepreneur, les choses sont différentes. Dans les exemples ci-dessus, les valeurs initiales étaient assignées directement aux éléments de l'agrégat, mais les entrepreneurs ont une façon de forcer l'initialisation à se produire à travers une interface formelle. Donc, si vous avez un struct qui ressemble à cela :

 
Sélectionnez
struct Y {
  float f;
  int i;
  Y(int a);
};

vous devez indiquer les appels au constructeur. La meilleure approche est explicite, comme celle-ci :

 
Sélectionnez
Y y1[] = { Y(1), Y(2), Y(3) };

Vous avez trois objets et trois appels au constructeur. Chaque fois que vous avez un constructeur, que ce soit pour un struct dont tous les membres sont public ou bien une classe avec des données membres private, toutes les initialisations doivent passer par le constructeur, même si vous utilisez l'initialisation d'agrégats.

Voici un deuxième exemple montrant un constructeur à paramètres multiples :

 
Sélectionnez
//: C06:Multiarg.cpp
// constructeur à paramètres multiples
// avec initialisation d'agrégat
#include <iostream>
using namespace std;
 
class Z {
  int i, j;
public:
  Z(int ii, int jj);
  void print();
};
 
Z::Z(int ii, int jj) {
  i = ii;
  j = jj;
}
 
void Z::print() {
  cout << "i = " << i << ", j = " << j << endl;
}
 
int main() {
  Z zz[] = { Z(1,2), Z(3,4), Z(5,6), Z(7,8) };
  for(int i = 0; i < sizeof zz / sizeof *zz; i++)
    zz[i].print();
} ///:~

Remarquez qu'il semble qu'un constructeur est appelé pour chaque objet du tableau.

VII-G. Les constructeurs par défaut

Un constructeur par défaut est un constructeur qui peut être appelé sans arguments. Un constructeur par défaut est utilisé pour créer un “objet basique”, mais il est également important quand on fait appel au compilateur pour créer un objet, mais sans donner aucun détail. Par exemple, si vous prenez la struct Y définit précédemment et l'utilisez dans une définition comme celle-là

 
Sélectionnez
Y y2[2] = { Y(1) };

Le compilateur se plaindra qu'il ne peut pas trouver un constructeur par défaut. Le deuxième objet dans le tableau veut être créé sans arguments, et c'est là que le compilateur recherche un constructeur par défaut. En fait, si vous définissez simplement un tableau d'objets Y,

 
Sélectionnez
Y y3[7];

le compilateur se plaindra parce qu'il doit avoir un constructeur par défaut pour initialiser tous les objets du tableau.

Le même problème apparaît si vous créez un objet individuel de cette façon :

 
Sélectionnez
Y y4;

Souvenez-vous, si vous avez un constructeur, le compilateur s'assure que la construction se produise toujours, quelle que soit la situation.

Le constructeur par défaut est si important que si(et seulement si) il n'y a aucun constructeur pour une structure ( struct ou class), le compilateur en créera automatiquement un pour vous. Ainsi ceci fonctionne :

 
Sélectionnez
//: C06:AutoDefaultConstructor.cpp
// Génération automatique d'un constructeur par défaut
 
class V {
  int i;  // privé
}; // Pas de constructeur
 
int main() {
  V v, v2[10];
} ///:~

Si un ou plusieurs constructeurs quelconques sont définis, cependant, et s'il n'y a pas de constructeur par défaut, les instances de V ci-dessus produiront des erreurs au moment de la compilation.

Vous pourriez penser que le constructeur généré par le compilateur doit faire une initialisation intelligente, comme fixer toute la mémoire de l'objet à zéro. Mais ce n'est pas le cas – cela ajouterait une transparence supplémentaire, mais serait hors du contrôle du programmeur. Si vous voulez que la mémoire soit initialisée à zéro, vous devez le faire vous-même en écrivant le constructeur par défaut explicitement.

Bien que le compilateur crée un constructeur par défaut pour vous, le comportement du constructeur généré par le compilateur est rarement celui que vous voulez. Vous devriez traiter ce dispositif comme un filet de sécurité, mais l'employer à petite dose. Généralement vous devriez définir vos constructeurs explicitement et ne pas permettre au compilateur de le faire pour vous.

VII-H. Résumé

Les mécanismes apparemment raffinés fournis par le C++ devraient vous donner un indice fort concernant l'importance critique de l'initialisation et du nettoyage dans ce langage. Lorsque Stroustrup concevait le C++, une des premières observations qu'il a faites au sujet de la productivité en C était qu'une partie significative des problèmes de programmation sont provoqués par une initialisation incorrecte des variables. Il est difficile de trouver ce genre de bogues, et des problèmes similaires concernent le nettoyage incorrect. Puisque les constructeurs et les destructeurs vous permettent de garantir l'initialisation et le nettoyage appropriés (le compilateur ne permettra pas à un objet d'être créé et détruit sans les appels appropriés au constructeur et au destructeur), vous obtenez un contrôle et une sûreté complets.

L'initialisation agrégée est incluse de manière semblable – elle vous empêche de faire les erreurs typiques d'initialisation avec des agrégats de types intégrés et rend votre code plus succinct.

La sûreté pendant le codage est une grande question en C++. L'initialisation et le nettoyage sont une partie importante de celui-ci, mais vous verrez également d'autres questions de sûreté au cours de votre lecture.

VII-I. Exercices

Les solutions de exercices suivants peuvent être trouvés dans le document électronique The Thinking in C++ Annotated Solution Guide, disponible à petit prix sur www.BruceEckel.com.

  1. Écrire une classe simple appelée Simple avec un constructeur qui affiche quelque chose pour vous dire qu'il a été appelé. Dans le main( ), créez un objet de votre classe.
  2. Ajoutez un destructeur à l'Exercice 1 qui affiche un message qui vous dit qu'il a été appelé.
  3. Modifiez l'Exercice 2 pour que la classe contienne un membre int. Modifiez le constructeur pour qu'il prenne un int en argument qui sera stocké dans le membre de la classe. Le constructeur et le destructeur doivent afficher la valeur de l' int dans leur message, afin que vous puissiez voir les objets lorsqu'ils sont créés et détruits.
  4. Montrez que les destructeurs sont appelés même quand on utilise un goto pour sortir d'une boucle.
  5. Écrivez deux boucles for qui affichent les valeurs de 0 à 10. Dans la première, définissez le compteur de boucle avant la boucle for, et dans la seconde, définissez le compteur de boucle dans l'expression de contrôle de la boucle for. Dans la deuxième partie de cet exercice, donnez au compteur de la deuxième boucle le même nom que le compteur de la première et regardez la réaction du compilateur.
  6. Modifiez les fichiers Handle.h, Handle.cpp, et UseHandle.cpp de la fin du chapitre 5 pour utiliser des constructeurs et des destructeurs.
  7. Utilisez l'initialisation agrégée pour créer un tableau de double pour lequel vous spécifiez la taille, mais sans remplir aucun élément. Affichez ce tableau en utilisant sizeof pour déterminer la taille du tableau. Maintenant créez un tableau de double en utilisante l'initialisation agrégée and le compteur automatique. Affichez le tableau.
  8. Utilisez l'initialisation agrégée pour créer un tableau d'objets string. Créez une Stack pour contenir ces string s et déplacez-vous dans votre tableau, empilez chaque string dans votre Stack. Pour terminer, dépilez les string s de votre Stack et affichez chacune d'elles.
  9. Illustrez le comptage automatique et l'initialisation agrégée avec un tableau d'objets de la classe que vous avez créée à l'Exercice 3. Ajoutez une fonction membre à cette classe qui affiche un message. Calculez la taille du tableau et déplacez-vous dedans en appelant votre nouvelle fonction membre.
  10. Créez une classe sans aucun constructeur, et montrez que vous pouvez créer des objets avec le constructeur par défaut. Maintenant, créez un constructeur particulier (avec des arguments) pour cette classe, et essayez de compiler à nouveau. Expliquez ce qui se passe.

précédentsommairesuivant
C99, La version à jour du standard C, permet aux variables d'être définies à n'importe quel point d'une portée, comme C++.
Un ancien brouillon du standard du C++ dit que la durée de vie de la variable se prolonge jusqu'à la fin de la portée qui contient la boucle for. Certains compilateurs implémentent toujours cela, mais ce n'est pas correct. Votre code ne sera portable que si vous limitez la portée à la boucle for.
Le langage Java considère ceci comme une si mauvaise idée qu'il signale un tel code comme une erreur.
D'accord, vous pourriez probablement en jouant avec des pointers, mais vous seriez très, très méchants.
Dans le Volume 2 de ce livre (disponible gratuitement à www.BruceEckel.com), vous verrez une manière plus succinte de calculer la taille d'un tableau en utilisant les templates.

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.