6. Initialisation & 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 manoeuvrer 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. Un grand 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 struture à 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 subtiles 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.
6-1. Initialisation garantie avec le constructeur▲
Les deux classes Stash/ cachetteet Stack/piledé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é 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éfinie.
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 choisi 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é automatiquement à l'initialisation.
Voici une classe simple avec un constructeur:
class
X {
int
i;
public
:
X(); // Constructeur
}
;
Maintenant, quand un objet est défini,
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ù aest définie, le constructeur est appelé automatiquement. C'est le compilateur qui insère discrètement l'appel à X::X( )pour l'objet aau 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 appellé. Dans le cas d'un constructeur, cependant, thispointe sur un block 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 Arbrea un constructeur qui prend un seul entier en argument donnant la hauteur de l'arbre, vous devez créer un objet arbre comme cela:
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éciale 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. Si il y avait une valeur de retour, et si vous pouviez sélectionner la votre, 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é.
6-2. 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 quelquechose 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 :
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 gotopour sauter d'une portée. ( gotoexiste 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 :
//: 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'ouvertude de l'accolade"
<<
endl;
{
Tree t(12
);
cout <<
"apres la création du Tree"
<<
endl;
t.printsize();
t.grow(4
);
cout <<
"avant la fermeture de l'accolade"
<<
endl;
}
cout <<
"apres la fermeture de l'accolade"
<<
endl;
}
///
:~
Voici la sortie du programme précédent :
avant l'ouverture de l'accolade
apres 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
apres la fermeture de l'accolade
Vous pouvez voir que le destructeur est automatiquement appelé à la fermeture de la portée qui entoure l'objet.
6-3. Elimination 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 connaîtrez cette information d'initialisation au début de la portée? En générale, vous ne la connaîtrez 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és se promenant dans votre système. Le C n'en a rien à faire ; en fait, le C encouragecette 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 (38).
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. A 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:
//: 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 retvalest définie, initialisé, et utilisé pour récupérer l'entrée de l'utilisateur, et puis yet gsont 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 connaître le type d'une variable.
6-3-1. les boucles▲
En C++, vous verrez souvent un compteur de boucle fordéfini directement dans l'expression for:
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 embarassent les nouveaux programmeurs en C++.
Les variables iet jsont 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 iet j, donc vous n'avez pas besoin d'utiliser des noms malcommodes comme i_boucle_compteurpour plus de clarté.
Cependant, vous pouvez vous tromper si vous supposez que la durée de vie des variables iet jse prolonge au delà de la portée de la boucle for – ce n'est pas le cas (39).
Le chapitre 3 souligne que les déclarations whileet switchpermettent é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 (40).
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.
6-3-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 (41). 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 switchou à un endroit qu'un gotopeut sauter. Ne pas commenter les déclarations dans le code suivant produira des warnings ou des erreurs :
//: 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 gotoet le switchpeuvent tout 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.
6-4. Stash avec constructueur 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 Stashutilisant les constructeurs et destructeurs :
//: 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 :
//: 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 constatez que les fonctions require.hsont 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 coût 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 Stashapparaissent 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 :
//: 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 intStashet stringStashsortent 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 fontionnerait 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 Stacksuivant, vous verrez que les pointeurs sont utilisés pour éviter ce problème, et dans un prochain chapitre le Stashsera modifié afin qu'il utilise des pointeurs.
6-5. 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 newet delete. Voici le fichier d'en-tête modifié :
//: 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 Stackqui ait un constructeur et un destructeur, mais la structLinkimbriquée également :
//: 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 dataet next, ainsi dans Stack::push( )la ligne
head =
new
Link(dat,head);
ne fait pas qu'allouer un nouveau Link(en utilisant la création dynamique d'objet 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 Linkne fait rien – en particulier, pourquoi ne supprime-t-il pas le pointeur data? Il y a deux problèmes. Au chapitre 4, où la Stacka été présentée, on a précisé que vous ne pouvez pas correctement supprimer un pointeur voids'il pointe un objet (une affirmation qui sera prouvée au Chapitre 13). En outre, si le destructeur de Linksupprimait 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 Stackn'est pas vide lors de la destruction.
Puisque l'allocation et la désallocation des objets Linksont 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 voussoyez responsable de supprimer les pointeurs qui arrivent de pop( ):
//: 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 textlinessont 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.
6-6. Initialisation d'aggrégat▲
Un aggrégatest exactement ce qu'il a l'air d'être : un paquet de choses rassemblées ensembles. Cette définition inclut des aggrégats de type mixte, comme les structures et les classes. Un tableau est un aggrégat d'un type unique.
Initialiser les aggrégats peut être enclin à l'erreur et fastidieux. L' initialisation d'aggrégatdu C++ rend l'opération beaucoup plus sure. Quand vous créez un objet qui est un aggré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'aggrégat auquel vous avez à faire, 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 :
int
a[5
] =
{
1
, 2
, 3
, 4
, 5
}
;
Si vous essayez de passer plus de valeurs qu'il n'y a d'élements dans le tableau, le compilateur génère un message d'erreur. Mais que se passe-t-il si vous passez moinsde valeurs ? Par exemple :
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 :
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. (42):
for
(int
i =
0
; i <
sizeof
c /
sizeof
*
c; i++
)
c[i]++
;
Comme les structures sont également des aggrégats, elles peuvent être initialisées de façon similaire. Comme les membres d'un structtype C sont tous public, ils peuvent être initialisés directement :
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 :
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 publicmais qu'il y a un contructeur, les choses sont différentes. Dans les exemples ci-dessus, les valeurs initiales étaient assignées directement aux éléments de l'aggrégat, mais les contructeurs ont une façon de forcer l'initialisation à se produire à travers une interface formelle. Donc, si vous avez un structqui ressemble à cela :
struct
Y {
float
f;
int
i;
Y(int
a);
}
;
vous devez indiquez les appels au constructeur. La meilleure approche est explicite, comme celle-ci :
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 structdont tous les membres sont publicou bien une classe avec des données membres private, toutes les initialisations doivent passer par le constructeur, même si vous utilisez l'intialisation d'aggrégats.
Voici un deuxième exemple montrant un constructeur à paramètres multiples :
//: C06:Multiarg.cpp
// constructeur à paramètres multiples
// avec initialisation d'aggré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.
6-7. Les constructeurs par défaut▲
Un constructeur par défautest 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 Ydéfinit précédemment et l'utilisez dans une définition comme celle-là
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,
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 :
Y y4;
Souvenez-vous, si vous avez un constructeur, le compilateur s'assure que la construction se produise toujours, quelque 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 ( structou class), le compilateur en créera automatiquement un pour vous. Ainsi ceci fonctionne :
//: 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 Vci-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.
6-8. 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 incorrectes 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 garantirl'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.
6-9. 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.
- Ecrire une classe simple appelée Simpleavec un constructeur qui affiche quelque chose pour vous dire qu'il a été appelé. Dans le main( ), créez un objet de votre classe.
- Ajoutez un destructeur à l'Exercice 1 qui affiche un message qui vous dit qu'il a été appelé.
- Modifiez l'Exercice 2 pour que la classe contienne un membre int. Modifiez le constructeur pour qu'il prenne un inten argument qui sera stocké dans le membre de la classe. Le constructeur et le destructeur doivent afficher la valeur de l' intdans leur message, afin que vous puissiez voir les objets lorsqu'ils sont créés et détruits.
- Montrez que les destructeurs sont appelés même quand on utilise un gotopour sortir d'une boucle.
- Ecrivez deux boucles forqui 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.
- Modifiez les fichiers Handle.h, Handle.cpp, et UseHandle.cppde la fin du chapitre 5 pour utiliser des constructeurs et des destructeurs.
- Utilisez l'initialisation agrégée pour créer un tableau de doublepour lequel vous spécifiez la taille mais sans remplir aucun élément. Affichez ce tableau en utilisant sizeofpour déterminer la taille du tableau. Maintenant créez un tableau de doubleen utilisante l'initialisation agrégée andle compteur automatique. Affichez le tableau.
- Utilisez l'initialisation agrégée pour créer un tableau d'objets string. Créez une Stackpour contenir ces strings et déplacez-vous dans votre tableau, empilez chaque stringdans votre Stack. Pour terminer, dépilez les strings de votre Stacket affichez chacune d'elles.
- Illustrez le comptage automatique et l'initialisation agrégée avec un tableau d'objets de la classe que vous avez créé à 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.
- 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 essayer de compiler à nouveau. Expliquez ce qui se passe.