Penser en C++

Volume 1


précédentsommairesuivant

4. Abstraction des données

C++ est un outil destiné à augmenter la productivité. Sinon, pourquoi feriez-vous l'effort (et c'est un effort, indépendamment de la facilité que nous essayons de donner à cette transition)

de passer d'un langage que vous connaissez déjà et avec lequel vous êtes productif à un nouveau langage avec lequel vous serez moins productif l'espace de quelques temps, jusqu'à ce que vous le maîtrisiez? C'est parce que vous avez été convaincus des avantages importants que vous allez obtenir avec ce nouvel outil.

La productivité, en termes de programmation informatique, signifie qu'un nombre réduit de personnes pourront écrire des programmes plus complexes et plus impressionnants en moins de temps. Il y a certainement d'autres enjeux qui interviennent lors du choix d'un langage, comme l'efficacité (la nature même du langage est-elle source de ralentissement et de gonflement du code source?), la sûreté (le langage vous aide-t'il à assurer que votre programme fera toujours ce que vous avez prévu, et qu'il traîte les erreurs avec élégance?), et la maintenance (le langage vous aide-t'il à créer du code facile à comprendre, à modifier, et à étendre?). Ce sont, à n'en pas douter, des facteurs importants qui seront examinés dans cet ouvrage.

La productivité en tant que telle signifie qu'un programme dont l'écriture prenait une semaine à trois d'entre vous, ne mobilisera maintenant qu'un seul d'entre vous durant un jour ou deux. Cela touche l'économie à plusieurs niveaux. Vous êtes content, car vous récoltez l'impression de puissance qui découle de l'acte de construire quelque chose, votre client (ou patron) est content, car les produits sont développés plus rapidement et avec moins de personnel, et les consommateurs sont contents, car ils obtiennent le produit à meilleur prix. La seule manière d'obtenir une augmentation massive de productivité est de s'appuyer sur le code d'autres personnes, c'est-à-dire d'utiliser des bibliothèques.

Une bibliothèque est simplement une collection de codes qu'une tierce personne a écrits et assemblés dans un paquetage. Souvent, un paquetage minimal se présente sous la forme d'un fichier avec une extension telle que lib et un ou plusieurs fichiers d'en-tête destinés à informer votre compilateur du contenu de la bibliothèque. L'éditeur de liens sait comment rechercher au sein du fichier de la bibliothèque et extraire le code compilé approprié. Mais il s'agit là seulement d'une manière de distribuer une bibliothèque. Sur des plateformes qui couvrent plusieurs architectures, telles que Linux/Unix, la seule façon de distribuer une bibliothèque est de la distribuer avec son code source, de manière qu'il puisse être reconfiguré et recompilé sur la nouvelle cible.

Ainsi, l'usage de bibliothèques est le moyen le plus important destiné à accroître la productivité, et un des objectifs de conception principaux de C++ est de rendre l'utilisation de bibliothèques plus aisée. Ceci implique qu'il y a quelque chose de compliqué concernant l'utilisation de bibliothèques en C. La compréhension de ce facteur vous donnera un premier aperçu de la conception de C++, et par conséquent un aperçu de comment l'utiliser.

4.1. Une petite bibliothèque dans le style C

Une bibliothèque commence habituellement comme une collection de fonctions, mais si vous avez utilisé des bibliothèques C écrites par autrui, vous savez qu'il s'agit généralement de plus que cela, parce que la vie ne se limite pas à des comportements, des actions et des fonctions. On y trouve également des caractéristiques (bleu, livres, texture, luminance), qui sont représentées par des données. Et lorsque vous commencez à travailler avec un ensemble de caractéristiques en C, il est très pratique de les rassembler dans une structure, particulièrement si vous désirez représenter plus d'un objet similaire dans l'espace de votre problème. De cette manière, vous pouvez définir une variable du type de cette structure pour chaque objet.

Ainsi, la plupart des bibliothèques C se composent d'un ensemble de structures et d'un ensemble de fonctions qui agissent sur ces structures. Comme exemple de ce à quoi un tel système peut ressembler, considérez un objet qui se comporte comme un tableau, mais dont la taille peut être établie à l'exécution, lors de sa création. Je l'appellerai CSTash. Bien qu'il soit écrit en C++, il utilise un style qui correspond à ce que vous écririez en C:

 
Sélectionnez

//: C04:CLib.h
// Fichier d'en-tête pour une bibliothèque
// écrite dans le style C
// Un entité semblable à un tableau créée à l'exécution
 
typedef struct CStashTag {
  int size;      // Taille de chaque espace
  int quantity;  // Nombre d'espaces de stockage
  int next;      // Prochain espace libre
  // Tableau d'octets alloué dynamiquement:
  unsigned char* storage;
} CStash;
 
void initialize(CStash* s, int size);
void cleanup(CStash* s);
int add(CStash* s, const void* element);
void* fetch(CStash* s, int index);
int count(CStash* s);
void inflate(CStash* s, int increase);
///:~ 

Un nom tel que CStashTag est généralement employé pour une structure au cas où vous auriez besoin de référencer cette structure à l'intérieur d'elle-même. Par exemple, lors de la création d'une liste chaînée(chaque élément dans votre liste contient un pointeur vers l'élément suivant), vous avez besoin d'un pointeur sur la prochaine variable struct, vous avez donc besoin d'un moyen d'identifier le type de ce pointeur au sein du corps même de la structure. Aussi, vous verrez de manière presque universelle le mot clé typedef utilisé comme ci-dessus pour chaque struct présente dans une bibliothèque C. Les choses sont faites de cette manière afin que vous puissiez traîter une structure comme s'il s'agissait d'un nouveau type et définir des variables du type de cette structure de la manière suivante:

 
Sélectionnez

CStash A, B, C;

Le pointeur storage est de type unsigned char*. Un unsigned char est la plus petite unité de stockage que supporte un compilateur C, bien que, sur certaines machines, il puisse être de la même taille que la plus grande. Cette taille dépend de l'implémentation, mais est souvent de un octet. Vous pourriez penser que puisque CStash est conçu pour contenir n'importe quel type de variable, void* serait plus approprié. Toutefois, l'idée n'est pas ici de traîter cet espace de stockage comme un bloc d'un type quelconque inconnu, mais comme un bloc contigu d'octets.

Le code source du fichier d'implémentation (que vous n'obtiendrez pas si vous achetez une bibliothèque commerciale - vous recevrez seulement un obj, ou un lib, ou un dll, etc. compilé) ressemble à cela:

 
Sélectionnez
//: C04:CLib.cpp {O}
// Implantation de l'exemple de bibliothèque écrite
// dans le style C
// Declaration de la structure et des fonctions:
#include "CLib.h"
#include <iostream>
#include <cassert> 
using namespace std;
// Quantité d'éléments à ajouter
// lorsqu'on augmente l'espace de stockage:
const int increment = 100;
 
void initialize(CStash* s, int sz) {
  s->size = sz;
  s->quantity = 0;
  s->storage = 0;
  s->next = 0;
}
 
int add(CStash* s, const void* element) {
  if(s->next >= s->quantity) //Il reste suffisamment d'espace?
    inflate(s, increment);
  // Copie l'élément dans l'espace de stockage,
  // en commençant au prochain espace vide:
  int startBytes = s->next * s->size;
  unsigned char* e = (unsigned char*)element;
  for(int i = 0; i < s->size; i++)
    s->storage[startBytes + i] = e[i];
  s->next++;
  return(s->next - 1); // Numéro de l'indice
}
 
void* fetch(CStash* s, int index) {
  // Vérifie les valeurs limites de l'indice:
  assert(0 <= index);
  if(index >= s->next)
    return 0; // Pour indiquer la fin
  // Produit un pointer sur l'élément désiré:
  return &(s->storage[index * s->size]);
}
 
int count(CStash* s) {
  return s->next;  // Eléments dans CStash
}
 
void inflate(CStash* s, int increase) {
  assert(increase > 0);
  int newQuantity = s->quantity + increase;
  int newBytes = newQuantity * s->size;
  int oldBytes = s->quantity * s->size;
  unsigned char* b = new unsigned char[newBytes];
  for(int i = 0; i < oldBytes; i++)
    b[i] = s->storage[i]; // Copie l'ancien espace vers le nouveau
  delete [](s->storage); // Ancien espace
  s->storage = b; // Pointe sur le nouvel espace mémoire
  s->quantity = newQuantity;
}
 
void cleanup(CStash* s) {
  if(s->storage != 0) {
   cout << "freeing storage" << endl;
   delete []s->storage;
  }
} ///:~

initialize() effectue le travail d'initialisation pour la structure CStash en fixant les variables internes à une valeur appropriée. Initialement, le pointeur storage est mis à zéro - aucun espace de stockage n'est alloué.

La fonction add() insère un élément dans le CStash à la prochaine position disponible. D'abord, elle contrôle si il reste de l'espace à disposition. Si ce n'est pas le cas, elle étend l'espace de stockage en utilisant la fonction inflate(), décrite plus loin.

Parce que le compilateur ne connaît pas le type spécifique de la variable stockée (tout ce que la fonction reçoit est un void*), vous ne pouvez pas simplement faire une affectation, ce qui serait certainement chose pratique. A la place, vous devez copier la variable octet par octet. La manière la plus évidente de réaliser cette copie est par itération sur les indices d'un tableau. Typiquement, storage contient déjà des octets de données, ce qui est indiqué par la valeur de next. Afin de démarrer avec le décalage d'octets approprié, next est multiplié par la taille de chaque élément (en octets) de manière à produire startBytes. Puis, l'argument element est converti en un unsigned char* de façon telle qu'il peut être adressé octet par octet et copié dans l'espace de stockage disponible. next est incrémenté de manière à indiquer le prochain emplacement disponible, et l'"indice" où la valeur a été placée afin de pouvoir récupérer cette dernière en utilisant cet indice avec fetch().

fetch() vérifie que l'indice n'est pas en dehors des limites et retourne l'adresse de la variable désirée, calculée à l'aide de l'argument index. Puisque index représente le nombre d'éléments à décaler dans CStash, il doit être multiplié par le nombre d'octets occupés par chaque entité pour produire le décalage numérique en octets. Lorsque ce décalage est utilisé pour accéder à un élément de storage en utilisant l'indiçage d'un tableau, vous n'obtenez pas l'adresse, mais au lieu de cela l'octet stocké à cette adresse. Pour produire l'adresse, vous devez utiliser l'opérateur adresse-de&.

count() peut au premier abord apparaître un peu étrange au programmeur C expérimenté. Cela ressemble à une complication inutile pour faire quelque chose qu'il serait probablement bien plus facile de faire à la main. Si vous avez une structure CStash appelée intStash, par exemple, il semble bien plus évident de retrouver le nombre de ses éléments en appelant inStash.next plutôt qu'en faisant un appel de fonction (qui entraîne un surcoût), tel que count(&intStash). Toutefois, si vous désirez changer la représentation interne de CStash, et ainsi la manière dont le compte est calculé, l'appel de fonction apporte la flexibilité nécessaire. Mais hélas, la plupart des programmeurs ne s'ennuieront pas à se documenter sur la conception "améliorée" de votre bibliothèque. Ils regarderont la structure et prendront directement la valeur de next, et peut-être même qu'ils modifieront next sans votre permission. Si seulement il y avait un moyen pour le concepteur de bibliothèque d'obtenir un meilleur contrôle sur de telles opérations! (Oui, c'est un présage.)

4.1.1. Allocation dynamique de mémoire

Vous ne savez jamais la quantité maximale de mémoire dont vous pouvez avoir besoin pour un CStash, ainsi l'espace mémoire pointé par storage est alloué sur le tas. Le tas est un grand bloc de mémoire utilisé pour allouer de plus petits morceaux à l'exécution. Vous utilisez le tas lorsque vous ne connaissez pas la taille de l'espace mémoire dont vous aurez besoin au moment où vous écrivez un programme. C'est-à-dire que seulement à l'exécution, vous découvrez que vous avez besoin de la mémoire nécessaire pour stocker 200 variables Airplane au lieu de 20. En C standard, les fonctions d'allocation dynamique de mémoire incluent malloc(), calloc, realloc, et free(). En lieu et place d'appels à la bibliothèque standard, le C++ utilise une approche plus sophistiquée (bien que plus simple d'utilisation) à l'allocation dynamique de mémoire qui est intégrée au langage via les mots clés new et delete.

La fonction inflate() utilise new pour obtenir un plus grand espace de stockage pour CStash. Dans ce cas, nous nous contenterons d'étendre l'espace mémoire et ne le rétrécirons pas, et l'appel à assert() nous garantira qu'aucun nombre négatif ne sera passé à inflate() en tant que valeur de increase. Le nouveau nombre d'éléments pouvant être stocké (après complétion de inflate()) est calculé en tant que newQuantity, et il est multiplié par le nombre d'octets par élément afin de produire newBytes, qui correspond au nombre d'octets alloués. De manière à savoir combien d'octets copier depuis l'ancien emplacement mémoire, oldBytes est calculée en utilisant l'ancienne valeur quantity.

L'allocation d'espace mémoire elle-même a lieu au sein de l'expression new, qui est l'expression mettant en jeu le mot clé new:

 
Sélectionnez
new unsigned char[newBytes];

La forme générale de l'expression new est:

new Type;

dans laquelle Type décrit le type de la variable que vous voulez allouer sur le tas. Dans ce cas, on désire un tableau de unsigned char de longueur newBytes, c'est donc ce qui apparaît à la place de Type. Vous pouvez également allouer l'espace pour quelque chose d'aussi simple qu'un int en écrivant:

 
Sélectionnez
new int;

et bien qu'on le fasse rarement, vous pouvez constater que la forme de l'expression reste cohérente.

Une expression new retourne un pointeur sur un objet du type exact de celui que vous avez demandé. Ainsi, si vous écrivez new Type, vous obtenez en retour un pointeur sur un Type. Si vous écrivez new int, vous obtenez un pointeur sur un int. Si vous désirez un newtableau de unsigned char, vous obtenez une pointeur sur le premier élément de ce tableau. Le compilateur s'assurera que vous affectiez la valeur de retour de l'expression new à un pointeur du type correct.

Bien entendu, à chaque fois que vous demandez de la mémoire, il est possible que la requête échoue, s'il n'y a plus de mémoire. Comme vous l'apprendrez, C++ possède des mécanismes qui entrent en jeu lorsque l'opération d'allocation de mémoire est sans succès.

Une fois le nouvel espace de stockage alloué, les données contenues dans l'ancien emplacement doivent être copiées dans le nouveau; Ceci est une nouvelle fois accompli par itération sur l'indice d'un tableau, en copiant un octet après l'autre dans une boucle. Après que les données aient été copiées, l'ancien espace mémoire doit être libéré de manière à pouvoir être utilisé par d'autres parties du programme au cas où elles nécessiteraient de l'espace mémoire supplémentaire. Le mot clé delete est le complément de new, et doit être utilisé pour libérer tout espace alloué par new(si vous oubliez d'utiliser delete, cet espace mémoire demeure indisponible, et si ce type de fuite de mémoire, comme on l'appelle communément, apparaît suffisamment souvent, il est possible que vous arriviez à court de mémoire). Par ailleurs, il existe une syntaxe spéciale destinée à effacer un tableau. C'est comme si vous deviez rappeler au compilateur que ce pointeur ne pointe pas simplement sur un objet isolé, mais sur un tableau d'objets: vous placez une paire de crochets vides à gauche du pointeur à effacer:

 
Sélectionnez
delete []myArray;

Une fois que l'ancien espace a été libéré, le pointeur sur le nouvel emplacement mémoire peut être affecté au pointeur storage, la variable quantity est ajustée, et inflate() a terminé son travail.

Notez que le gestionnaire du tas est relativement primitif. Il vous fournit des morceaux d'espace mémoire et les récupère lorsque vous les libérez. Il n'y a pas d'outil inhérent de compression du tas, qui compresse le tas de manière à obtenir de plus grands espaces libres. Si un programme alloue et libère de la mémoire sur le tas depuis un moment, vous pouvez finir avec un tas fragmenté qui possède beaucoup de mémoire libre, mais sans aucun morceau de taille suffisante pour allouer l'espace dont vous avez besoin maintenant. Un défragmenteur de tas complique le programme parce qu'il déplace des morceaux de mémoire, de manière telle que vos pointeurs ne conserveront pas leur valeur. Certains environnements d'exploitation possèdent un défragmenteur de tas intégré, mais ils exigent que vous utilisiez des manipulateurs spéciaux de mémoire (qui peuvent être convertis en pointeurs de manière temporaire, après avoir verrouillé l'espace mémoire en question pour le défragmenteur de tas ne puisse le déplacer) à la place des pointeurs. Vous pouvez également mettre en oeuvre votre propre schéma de compression du tas, mais ce n'est pas là une tâche à entreprendre à la légère.

Lorsque vous créez une variable sur la pile à la compilation, l'espace pour cette variable est automatiquement créé et libéré par le compilateur. Le compilateur sait exactement quelle quantité de mémoire est nécessaire, et il connaît la durée de vie des variables grâce à leur portée. Avec l'allocation dynamique de mémoire, toutefois, le compilateur ne sait pas de combien d'espace mémoire vous aurez besoin, et il ne connaît pas la durée de vie de cet espace. C'est-à-dire, la mémoire n'est pas libérée automatiquement. Pour cette raison, vous êtes responsable de la restitution de la mémoire à l'aide de delete, qui indique au gestionnaire du tas que l'espace mémoire en question peut être utilisé par le prochain appel à new. L'emplacement logique où ce mécanisme de libération doit être mis en oeuvre dans la bibliothèque, c'est dans la fonction cleanup(), parce que c'est à cet endroit que tout le nettoyage de fermeture est effectué.

Dans le but de tester la bibliothèque, deux CStash es sont créés. Le premier contient des int s et le deuxième contient un tableau de 80 char s:

 
Sélectionnez
//: C04:CLibTest.cpp
//{L} CLib
// Teste la bibliothèque écrite dans le style C
#include "CLib.h"
#include <fstream>
#include <iostream>
#include <string>
#include <cassert>
using namespace std;
 
int main() {
  // Définit les variables au début
  // d'un bloc, comme en C:
  CStash intStash, stringStash;
  int i;
  char* cp;
  ifstream in;
  string line;
  const int bufsize = 80;
  // Maintenant, rappelez-vous d'initialiser les variables:
  initialize(&intStash, sizeof(int));
  for(i = 0; i < 100; i++)
    add(&intStash, &i);
  for(i = 0; i < count(&intStash); i++)
    cout << "fetch(&intStash, " << i << ") = "
         << *(int*)fetch(&intStash, i)
         << endl;
  // Contient des chaînes de 80 caractères:
  initialize(&stringStash, sizeof(char)*bufsize);
  in.open("CLibTest.cpp");
  assert(in);
  while(getline(in, line))
    add(&stringStash, line.c_str());
  i = 0;
  while((cp = (char*)fetch(&stringStash,i++))!=0)
    cout << "fetch(&stringStash, " << i << ") = "
         << cp << endl;
  cleanup(&intStash);
  cleanup(&stringStash);
} ///:~

En suivant la forme requise par le C, toutes les variables sont créées au début de la portée de main(). Bien entendu, vous devez vous souvenir d'initialiser les variables CStash plus tard dans le bloc en appelant initialize(). Un des problèmes avec les bibliothèques C est que vous devez consciencieusement inculquer à l'utilisateur l'importance des fonctions d'initialisation et de nettoyage. Si ces fonctions ne sont pas appelées, il y aura de nombreux problèmes. Malheureusement, l'utilisateur ne se demande pas toujours si l'initialisation et le nettoyage sont obligatoires. Ils savent ce qu'ils veulent accomplir, et ils ne se sentent pas si concernés que cela en vous voyant faire de grands signes en clamant, "Hé, attendez, vous devez d'abord faire ça!" Certains utilisateurs initialisent même les éléments d'une structure par eux-même. Il n'existe aucun mécanisme en C pour l'empêcher (encore un présage).

La variable de type intStash est remplie avec des entiers, tandis que la variable de type stringStash est remplie avec des tableaux de caractères. Ces tableaux de caractères sont produits en ouvrant le fichier source, CLibTest.cpp, et en lisant les lignes de ce dernier dans une variable string appelée line, et puis en produisant un pointeur sur la représentation tableau de caractères de line à l'aide de la fonction membre c_str().

Après que chaque Stash ait été chargé en mémoire, il est affiché. La variable de type intStash est imprimée en utilisant une boucle for, qui utilise count() pour établir la condition limite. La variable de type stringStash est imprimée à l'aide d'une boucle while qui s'interrompt lorsque fetch() retourne zéro pour indiquer qu'on est en dehors des limites.

Vous aurez également noté une convertion supplémentaire dans

 
Sélectionnez
cp = (char*)fetch(&stringStash,i++)

C'est dû à la vérification plus stricte des types en C++, qui ne permet pas d'affecter simplement un void* à n'importe quel autre type (C permet de faire cela).

4.1.2. Mauvaises conjectures

Il y a un enjeu plus important que vous devriez comprendre avant que l'on regarde les problèmes généraux liés à la création de bibliothèques en C. Notez que le fichier d'en-tête CLib.hdoit être inclus dans chaque fichier qui fait référence à CStash parce que le compilateur est incapable de deviner à quoi cette structure peut ressembler. Toutefois, il peut deviner à quoi ressemble une fonction; ceci peut apparaître comme une fonctionnalité mais il s'agit là d'un piège majeur du C.

Bien que vous devriez toujours déclarer des fonctions en incluant un fichier d'en-tête, les déclarations de fonctions ne sont pas essentielles en C. Il est possible en C (mais pas en C++) d'appeler une fonction que vous n'avez pas déclarée. Un bon compilateur vous avertira que vous avez probablement intérêt à d'abord déclarer une fonction, mais ce n'est pas imposé pas la norme du langage C. Il s'agit là d'une pratique dangereuse parce que le compilateur C peut supposer qu'une fonction que vous appelez avec un int en argument possède une liste d'argument contenant int, même si elle contient en réalité un float. Ce fait peut entraîner des bugs qui sont très difficiles à démasquer, comme vous allez le voir.

Chaque fichier d'implantation séparé (avec l'extension .c) représente une unité de traduction. Cela signifie que le compilateur travaille séparément sur chaque unité de traduction, et lorsque qu'il est en train de travailler, il ne connaît que cette unité. Par conséquent, toute information que vous lui fournissez en incluant des fichiers d'en-tête est importante, car cela conditionne la compréhension qu'aura le compilateur du reste de votre programme. Les déclarations dans les fichiers d'en-tête sont particulièrement importantes, parce que partout où un fichier d'en-tête est inclus, le compilateur saura exactement quoi faire. Si, par exemple, vous avez une déclaration dans un fichier d'en-tête qui dit void func(float), le compilateur sait que si vous appelez cette fonction avec un argument entier, il doit convertir cet int en un float lors du passage de l'argument (ce processus est appelé promotion). En l'absence de déclaration, le compilateur C fera simplement l'hypothèse qu'une fonction func(int) existe, il n'effectuera pas la promotion, et la donnée erronée se verra ainsi passée silencieusement à func().

Pour chaque unité de traduction, le compilateur crée un fichier objet, avec l'extension .o ou .obj ou quelque chose de similaire. Ces fichiers objets, en plus du code de démarrage nécessaire, doivent être collectés par l'éditeur de liens au sein d'un programme exécutable. Durant l'édition des liens, toutes les références externes doivent être résolues. Par exemple, dans CLibTest.cpp, des fonctions telles que initialize() et fetch() sont déclarées (cela signifie que le compilateur est informé de ce à quoi elles ressemblent) et utilisées, mais pas définies. Elles sont définies ailleurs, dans CLib.cpp. Par conséquent, les appels dans CLibTest.cpp sont des références externes. L'éditeur de liens doit, lorsqu'il réunit tous les fichiers objets, traîter les références externes non résolues et trouver les adresses auxquelles elles font référence. Ces adresses sont placées dans le programme exécutable en remplacement des références externes.

Il est important de réaliser qu'en C, les références externes que l'éditeur de liens recherche sont simplement des noms de fonctions, généralement précédés d'un caractère de soulignement. Ainsi, tout ce que l'éditeur de liens a à faire, c'est de réaliser la correspondance entre le nom d'une fonction, lorsque celle-ci est appelée, et le corps de cette fonction dans un fichier objet, et c'est tout. Si vous faites accidentellement un appel que le compilateur interprète comme func(int) et qu'il y a un corps de fonction pour func(float) dans un autre fichier objet, l'éditeur de liens verra _func d'un côté et _func de l'autre, et il pensera que tout est en ordre. L'appel à func() placera un int sur la pile, alors que le corps de la fonction func() attend la présence d'un float au sommet de la pile. Si la fonction se contente de lire la valeur et n'écrit pas dans cet emplacement, cela ne fera pas sauter la pile. En fait, la valeur float qu'elle lit depuis la pile peut même avoir un sens. C'est pire, car il est alors plus difficile de découvrir le bug.

4.2. Qu'est-ce qui ne va pas?

Nous avons des capacités d'adaptation remarquables, y compris dans les situations dans lesquelles nous ne devrions peut être pas nous adapter. Le modèle de la bibliotheque CStash était une entrée en matière destinée aux programmeurs en langage C, mais si vous la regardiez pendant un moment, vous pourriez noter qu'elle est plutôt... maladroite. Quand vous l'employez, vous devez passer l'adresse de la structure à chaque fonction de la bibliothèque. En lisant le code, le mécanisme de fonctionnement de la bibliothèque se mélange avec la signification des appels de fonction, ce qui crée la confusion quand vous essayez de comprendre ce qui se passe.

Un des obstacles majeurs à l'utilisation de bibliothèques C est cependant le problème de collision des noms. C possède un espace de nom unique pour les fonctions; c'est-à-dire que, quand l'éditeur de liens recherche le nom d'une fonction, il regarde dans une liste principale unique. De plus, quand le compilateur travaille sur une unité de traduction, il ne peut travailler qu'avec une seule fonction ayant un nom donné.

Supposez maintenant que vous décidiez d'acheter deux bibliothèques différentes à deux fournisseurs différents, et que chaque bibliothèque dispose d'une structure qui doit etre initialisée et nettoyée. Chaque fournisseur décide que initialize( ) et cleanup( ) sont des noms adaptés. Si vous incluez leur deux fichiers d'en-tête dans une même unité de traduction, que fait le compilateur C? Heureusement, C vous donne une erreur, et vous indique qu'il y a une disparité dans les deux listes d'arguments des fonctions déclarées. Mais même si vous ne les incluez pas dans la même unité de traduction, l'éditeur de lien aura toujours des problèmes. Un bon éditeur de liens détectera qu'il y a une collision de noms, mais certains autres prendront le premier nom qu'ils trouvent, en cherchant dans la listes de fichiers objets selon l'ordre dans lequelle vous les lui avez donnés. (Ce peut même être considéré comme une fonctinonnalité du fait qu'il vous permet de remplacer une fonction de bibliothèque par votre propre version.)

D'une manière ou d'une autre, vous ne pourrez pas utiliser deux bibliothèques C contenant une fonction ayant un nom identique. Pour résoudre ce problème, les fournisseurs de bibliothèques ajouteront souvent une séquence unique de caractères au début de tous les noms de leurs fonctions. Ainsi initialize( ) et cleanup( ) deviendront CStash_initialize( ) et CStash_cleanup( ). C'est une chose logique à faire car cela “décore” le nom de la fonction avec le nom de la struct sur laquelle elle travaille.

Maintenant, il est temps de suivre la première étape vers la création de classes en C++. Les noms de variables dans une struct n'entrent pas en collision avec ceux des variables globales. Alors pourquoi ne pas tirer profit de ceci pour des noms de fonctions, quand ces fonctions opèrent sur une struct particulière ? Autrement dit, pourquoi ne pas faire des fonctions membres de struct s ?

4.3. L'objet de base

La première étape est exactement celle-là. Les fonctions C++ peuvent être placées à l'intérieur de structures sous la forme de "fonctions membres". Voilà à quoi peut ressembler le code après conversion de la version C de CStash en une version C++ appelée Stash:

 
Sélectionnez
//: C04:CppLib.h
// Bibliothèque dans le style C convertie en C++
 
struct Stash {
  int size;      // Taille de chaque espace
  int quantity;  // Nombre d'espaces de stockage
  int next;      // Prochain emplacement libre
   // Allocation dynamique d'un tableau d'octets:
  unsigned char* storage;
  // Fonctions!
  void initialize(int size);
  void cleanup();
  int add(const void* element);
  void* fetch(int index);
  int count();
  void inflate(int increase);
}; ///:~

Tout d'abord, notez qu'il n'y a pas de typedef. A la place de vous demander de créer un typedef, le compilateur C++ transforme le nom de la structure en un nouveau nom de type disponible pour le programme (de la même manière que int, char, float et double sont des noms de type).

Tous les membres données sont exactement les mêmes que précédemment, mais maintenant, les fonctions se trouvent à l'intérieur du corps de la structure. Par ailleurs, notez que le premier argument présent dans la version C de la bibliothèque a été éliminé. En C++, au lieu de vous forcer à passer l'adresse de la structure en premier argument de toutes les fonctions qui agissent sur cette structure, le compilateur le fait secrètement à votre place. Maintenant, les seuls arguments passés aux fonctions concernent ce que la fonction fait, et non le mécanisme sous-jacent lié au fonctionnement de cette fonction.

C'est important de réaliser que le code des fonctions est effectivement le même que dans la version C de la bibliothèque. Le nombre d'arguments est le même (même si vous ne voyez pas l'adresse de la structure, elle est toujours là), et il y a exactement un corps de fonction pour chaque fonction. C'est à dire que le simple fait d'écrire

 
Sélectionnez
Stash A, B, C;

ne signifie pas une fonction add() différente pour chaque variable.

Ainsi, le code généré est presque identique à celui que vous auriez écrit pour la version C de la bibliothèque. De manière assez intéressante, cela inclus la "décoration des noms" que vous auriez probablement mise en oeuvre afin de produire Stash_initialize(), Stash_cleanup(), etc. Lorsque le nom de fonction se trouve à l'intérieur d'une structure, le compilateur réalise effectivement les mêmes opérations. C'est pourquoi initialize() situé à l'intérieur de la structure Stash n'entre pas en collision avec une fonction appelée initialize() située dans une autre structure, ou même avec une fonction globale nommée initialize(). La plupart du temps vous n'avez pas besoin de vous préoccuper de la décoration des noms de fonctions - vous utilisez les noms non décorés. Mais parfois, vous avez besoin de pouvoir spécifier que cet initialize() appartient à la structure Stash, et pas à n'importe quelle autre structure. En particulier, lorsque vous définissez la fonction, vous avez besoin de spécifier de manière complète de quelle fonction il s'agit. Pour réaliser cette spécification complète, C++ fournit un opérateur ( ::) appelé opérateur de résolution de portée(appelé ainsi, car les noms peuvent maintenant exister sous différentes portées: à un niveau global ou au sein d'une structure). Par exemple, si vous désirez spécifier initialize(), qui appartient à Stash, vous écrivez Stash::initialize(int size). Vous pouvez voir ci-dessous comment l'opérateur de résolution de portée est utilisé pour la définition de fonctions:

 
Sélectionnez
//: C04:CppLib.cpp {O}
// Bibliothèque C convertie en C++
// Déclare une structure et des fonctions:
#include "CppLib.h"
#include <iostream>
#include <cassert>
using namespace std;
// Quantité d'éléments à ajouter
// lorsqu'on augmente l'espace de stockage:
const int increment = 100;
 
void Stash::initialize(int sz) {
  size = sz;
  quantity = 0;
  storage = 0;
  next = 0;
}
 
int Stash::add(const void* element) {
  if(next >= quantity) // Reste-il suffisamment de place?
    inflate(increment);
  // Copie element dans storage,
  // en commençant au prochain espace libre:
  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); // Numéro d'indice
}
 
void* Stash::fetch(int index) {
  // Check index boundaries:
  assert(0 <= index);
  if(index >= next)
    return 0; // Pour indiquer la fin
  // Retourne un pointeur sur l'élément désiré:
  return &(storage[index * size]);
}
 
int Stash::count() {
  return next; // Nombre d'éléments dans CStash
}
 
void Stash::inflate(int increase) {
  assert(increase > 0);
  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 l'ancien dans le nouveau
  delete []storage; // Ancienne espace 
  storage = b; // Pointe vers le nouvel espace mémoire
  quantity = newQuantity;
}
 
void Stash::cleanup() {
  if(storage != 0) {
    cout << "freeing storage" << endl;
    delete []storage;
  }
} ///:~

Il y a plusieurs autres points qui diffèrent entre C et C++. Tout d'abord, les déclarations dans les fichiers d'en-tête sont requises par le compilateur. En C++, vous ne pouvez pas appeler une fonction sans la déclarer d'abord. Faute de quoi, le compilateur vous renverra un message d'erreur. C'est une manière importante de s'assurer que les appels de fonctions sont cohérents entre l'endroit où elles sont appelées et l'endroit où elles sont définies. En vous forçant à déclarer une fonction avant de l'appeler, le compilateur C++ s'assure virtuellement que vous réaliserez cette déclaration en incluant le fichier d'en-tête. Si vous incluez également le même fichier d'en-tête à l'endroit où les fonctions sont définies, alors le compilateur vérifie que la déclaration dans l'en-tête et dans la définition de la fonction correspondent. Cela signifie que le fichier d'en-tête devient un dépôt validé de déclarations de fonctions et assure que ces fonctions seront utilisées d'une manière cohérente dans toutes les unités de traduction du projet.

Bien entendu, les fonctions globales peuvent toujours être déclarées à la main à chaque endroit où elles sont définies et utilisées. (C'est si ennuyeux à réaliser que cette manière de faire est très improbable.) Toutefois, les structures doivent toujours être déclarées avant qu'elles soient définies ou utilisées, et l'endroit le plus approprié pour y placer la définition d'une structure, c'est dans un fichier d'en-tête, à l'exception de celles que vous masquer intentionnellement dans un fichier.

Vous pouvez observer que toutes les fonctions membres ressemblent à des fonctions C, à l'exception de la résolution de portée et du fait que le premier argument issu de la version C de la bibliothèque n'apparaît plus de façon explicite. Il est toujours là, bien sûr, parce que la fonction doit être en mesure de travailler sur une variable struct particulière. Mais notez qu'à l'intérieur d'une fonction membre, la sélection du membre a également disparu! Ainsi, à la place d'écrire s->size = sz; vous écrivez size = sz; et éliminez le s-> ennuyeux, qui n'ajoutait de toute manière véritablement rien au sens de ce que vous vouliez faire. Le compilateur C++ fait cela à votre place. En fait, il utilise le premier argument "secret" (l'adresse de la structure que nous passions auparavant à la main) et lui applique le sélecteur de membre à chaque fois que vous faites référence à une donnée membre de cette structure. Cela signifie qu'à chaque fois que vous vous trouvez à l'intérieur d'une fonction membre d'une autre structure, vous pouvez faire référence à n'importe quel membre (inclus une autre fonction membre) en utilisant simplement son nom. Le compilateur recherchera parmi les noms locaux à la structure avant de rechercher une version globale du même nom. Vous vous rendrez compte que cette fonctionnalité signifie que votre code sera non seulement plus facile à écrire, il sera aussi beaucoup plus facile à lire.

Mais que ce passe-t'il si, pour une raison quelconque, vous désirez manipuler l'adresse de la structure? Dans la version C de la bibliothèque, c'était facile, car le premier argument de chaque fonction était un pointeur de type CStash* appelé s. En C++, les choses sont encore plus cohérentes. Il y a un mot clé spécial, appelé this, qui produit l'adresse de la structure. C'est l'équivalent de s dans la version C de la bibliothèque. Ainsi, on peut retrouver le style utilisé en C en écrivant:

 
Sélectionnez
this->size = Size;

Le code généré par le compilateur est exactement le même, si bien que vous n'avez pas besoin d'utiliser this de cette manière; occasionnellement, vous verrez du code où les gens utilisent this-> partout de façon explicite, mais cela n'ajoute rien à la signification du code et c'est souvent révélateur d'un programmeur inexpérimenté. Habituellement, vous n'utilisez pas souvent this, mais lorsque vous en avez besoin, il est là (certains des exemples que vous rencontrerez plus loin dans le livre utilisent this).

Il reste un dernier point à mentionner. En C, vous pouvez affecter un pointeur de type void* à n'importe quel autre pointeur de la façon suivante:

 
Sélectionnez
int i = 10;
void* vp = &i; // OK aussi bien en C qu'en C++
int* ip = vp; // Acceptable uniquement en C

et il n'y avait aucune plainte de la part du compilateur. Mais en C++, cette instruction n'est pas autorisée. Pourquoi? Parce que C n'est pas aussi précis au sujet de l'information de type, ainsi il vous autorise à affecter un pointeur avec un type non spécifié à un pointeur avec un type spécifié. Rien de cela avec C++. Le typage est une chose critique en C++, et le compilateur sort ses griffes lorsqu'il aperçoit des violations au sujet de l'information de type. Ca a toujours été important, mais ça l'est spécialement en C++, parce que vous avez des fonctions membres à l'intérieur des structures. Si vous pouviez passer des pointeurs de struct n'importe comment en toute impunité en C++, il est possible que vous finissiez par appeler une fonction membre pour une structure qui n'existe même pas pour la structure effectivement traitée! Une voie directe vers le désastre. Par conséquent, tandis que C++ autorise l'affectation de n'importe quel type de pointeur à un void*(c'était la raison d'être originelle de void*, qui a la contrainte d'être suffisamment grand pour contenir un pointeur de n'importe quel type), il ne vous permettra pas d'affecter un pointeur void à n'importe quel autre type de pointeur. Une conversion est toujours nécessaire pour avertir le lecteur et le compilateur que vous voulez véritablement le traîter comme le type de destination.

Ce point soulève un aspect intéressant. Un des objectifs importants de C++ est de compiler autant de code C existant que possible afin de permettre une transition aisée vers ce nouveau langage. Malgré tout, cela ne signifie pas que n'importe quel code autorisé en C sera automatiquement accepté en C++. Il y a de nombreuses choses qu'un compilateur C laisse passer qui sont dangereuses et susceptibles d'entraîner des erreurs. (Nous les étudierons au fur et à mesure que le livre progresse.) Le compilateur C++ génère des avertissements et des erreurs dans ces situations. Il s'agit là souvent plus d'un avantage que d'un obstacle. En fait, il existe de nombreuses situations où vous essayez de traquer une erreur en C et ne parvenez pas à la trouver, mais aussitôt que vous recompilez le programme en C++, le compilateur montre le problème du doigt! En C, vous vous rendrez souvent compte que vous pouvez amener le programme à compiler, mais que la prochaine étape est de le faire fonctionner correctement. En C++, lorsque le programme compile comme il le doit, souvent en plus, il fonctionne! C'est parce que le langage est beaucoup plus strict avec les types.

Vous pouvez voir un certain nombre de nouvelles choses dans la façon dont la version C++ de Stash est utilisée dans le programme de test suivant:

 
Sélectionnez
//: C04:CppLibTest.cpp
//{L} CppLib
// Test de la bibliothèque C++
#include "CppLib.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main() {
  Stash intStash;
  intStash.initialize(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;
  // Contient des chaînes de 80 caractères
  Stash stringStash;
  const int bufsize = 80;
  stringStash.initialize(sizeof(char) * bufsize);
  ifstream in("CppLibTest.cpp");
  assure(in, "CppLibTest.cpp");
  string line;
  while(getline(in, line))
    stringStash.add(line.c_str());
  int k = 0;
  char* cp;
  while((cp =(char*)stringStash.fetch(k++)) != 0)
    cout << "stringStash.fetch(" << k << ") = "
         << cp << endl;
  intStash.cleanup();
  stringStash.cleanup();
} ///:~

Une chose que vous noterez, c'est que les variables sont toutes définies "à la volée" (comme introduit au chapitre précédent). C'est à dire qu'elles sont définies à n'importe quel endroit au sein d'un bloc, au lieu d'être contraintes - comme en C - de l'être au début du bloc.

Le code est relativement similaire à CLibTest.cpp, mais lorsqu'une fonction membre est appelée, l'appel a lieu en utilisant l'opérateur de sélection de membre ' .' précédé par le nom de la variable. C'est un syntaxe pratique parce qu'elle imite la sélection d'un membre donnée de la structure. La différence est qu'il s'agit là d'une fonction membre, et qu'elle possède une liste d'arguments.

Bien entendu, l'appel que le compilateur génère effectivement ressemble beaucoup plus à la fonction originale de la bibliothèque C. Ainsi, en considérant la décoration du nom et le passage de this, l'appel de fonction C++ inStash.initialize(sizeof(int), 100) devient quelque chose comme Stash_initialize(&intStash, sizeof(int), 100).Si vous vous demandez un jour ce qui se passe sous le capot, souvenez-vous que cfront, le compilateur C++ original de AT&T, produisait en sortie du code C qui était alors compilé par un compilateur C sous-jacent. Cette approche signifiait que cfront pouvait être rapidement porté sur n'importe quelle machine possédant un compilateur C, et cela a contribué à favoriser la dissémination rapide de la technologie du compilateur C++. Mais, parce que le compilateur C++ devait générer du C, vous savez qu'il doit être possible, d'une manière ou d'une autre, de représenter la syntaxe C++ en C (certains compilateurs vous permettent encore de produire du code C).

Il ya un autre changement par rapport à CLibTest.cpp qui consiste en l'introduction du fichier d'en-tête require.h. Il s'agit d'un fichier d'en-tête que j'ai créé pour ce livre afin d'effectuer une vérification d'erreur plus sophistiquée que celle fournie par assert(). Il contient plusieurs fonctions dont celle utilisée ici et appelée assure(), qui est utilisée pour les fichiers. Cette fonction contrôle que le fichier a été ouvert avec succès, et dans le cas contraire, affiche sur le flux d'erreur standard que le fichier n'a pu être ouvert (elle a donc besoin du nom du fichier en second argument) et quitte le programme. Les fonctions de require.h seront utilisées tout au long de ce livre, en particulier pour nous assurer que la ligne de commande comporte le bon nombre d'arguments et que les fichiers sont ouverts proprement. Les fonctions de require.h remplacent le code de vérification d'erreur répétitif et qui consitute une distraction, et fournissent essentiellement des messages d'erreur utiles. Ces fonctions seront expliquées de façon de complète plus loin dans le livre.

4.4. Qu'est-ce qu'un objet?

Maintenant que vous avez vu un premier exemple, il est temps de faire marche arrière et de jeter un oeil à la terminologie. Le fait d'apporter des fonctions dans une structure est la base de ce que le C++ apporte au C, et cela introduit une nouvelle façon de penser les structures: comme des concepts. En C, une struct est une agglomération de données, un moyen d'empaqueter les données de manière à ce que vous puissiez les traiter dans un bloc. Mais il est difficile d'y penser autrement que comme une commodité de programmation. Les fonctions qui agissent sur ces structures sont ailleurs. Cependant, avec les fonctions dans le paquetage, la structure devient une nouvelle créature, capable de décrire à la fois des caractéristiques (comme le fait une struct C) et des comportements. Le concept de l'objet, une entité libre et bornée qui peut se souvenir et agir, se suggère de lui-même.

En C++, un objet est simplement une variable, et la plus pure définition est “une zone de stockage” (c'est un moyen plus spécifique de dire, “un objet doit avoir un identifiant unique”, qui dans le cas du C++ est une adresse mémoire unique). C'est un emplacement dans lequel vous pouvez stocker des données, et qui implique qu'il y a également des opérations qui peuvent être effectuées sur ces données.

Malheureusement, il n'y a pas complète uniformité entre les langages sur ces termes, bien qu'ils soient assez bien acceptés. Vous rencontrerez parfois des désaccords sur ce qu'est un langage orienté objet, bien que cela semble raisonnablement bien défini maintenant. Il y a des langages qui sont à base d'objets, ce qui signifie qu'il y a des objets comme les structures avec fonctions du C++ que vous avez vu jusqu'à présent. Ce n'est cependant qu'une partie de la condition nécessaire à un langage orienté objet, et les langages qui s'arrêtent à l'empaquetage des fonctions dans les structures de données sont à base d'objets, et non orientés objet.

4.5. Typage de données abstraites

La capacité d'empaqueter des données avec des fonctions vous permet de créer de nouveaux types de données. Cela est généralement appelé encapsulation(33). Un type de donnée existant peut avoir plusieurs morceaux de données empaquetées ensemble. Par exemple, un float a un exposant, une mantisse, et un bit de signe. Vous pouvez lui dire de faire des choses: l'ajouter à un autre float ou à un int, et ainsi de suite. Il a des caractéristiques et un comportement.

La définition de Stash crée un nouveau type de données. Vous pouvez ajouter ( add( )), chercher ( fetch( )), et gonfler ( inflate( )). Vous en créez un en disant Stash s, tout comme vous créez un float en disant float f. Un Stash a aussi des caractéristiques et un comportement. Bien qu'il se comporte comme un type réel, intégré, nous nous y référons comme à un type de données abstrait, peut-être parce qu'il nous permet de abstraire un concept de l'espace des problèmes vers l'espace des solutions. En plus, le compilateur C++ le traite comme un nouveau type de données, et si vous dites qu'une fonction attend un Stash, le compilateur s'assurera que vous passiez un Stash à cette fonction. Ainsi le même niveau de vérification de type se produit avec les types de données abstraits (parfois appelés types définis par l'utilisateur) qu'avec les types intégrés.

Vous pouvez immédiatement voir la différence, cependant, à la façon dont vous effectuez des opérations sur les objets. Vous dites object.memberFunction(arglist). C'est “l'appel d'une fonction membre pour un objet.” Mais dans le jargon orienté objet, c'est également mentionné comme “l'envoi d'un message à un objet.” Pour un Stash s, l'instruction s.add(&i) envoie à s un message disant, “ ajoute toi ceci.” En réalité, la programmation orientée objet peut se résumer en une seule phrase: envoyer des messages à des objets. C'est vraiment tout ce que vous faites - créer un groupe d'objets et leur envoyer des messages. L'astuce, bien sûr, est de vous représenter ce que sont vos objets et vos messages , mais une fois que vous l'avez fait, l'implémentation en C++ est étonnamment simple.

4.6. Détails sur les objest

Une question qui revient souvent dans les séminaires est “Quelle est la taille d'un objet, et à quoi ressemble-t-il ?” La réponse dépend “ce que vous voulez faire d'un struct C.” En fait, le code que le compilateur C produit pour un struct C (avec aucun ornement C++) est souvent exactement le même que celui produit par un compilateur C++. C'est rassurant pour ces programmeurs C qui se reposent sur les détails de taille et de disposition de leur code, et qui,pour certaines raisons, accédent directement aux octets de la structure au lieu d'employer des identifiants (compter sur une taille et une disposition particulières pour une structure est une activité non portable).

La taille d'une structure est la taille combinée de tout ses membres. Quelquefois quand le compilateur génère un struct, il ajoute des octets supplémentaires pour faire ressortir nettement les frontières - ceci peut augmenter l'efficacité de l'exécution. Dans le Chapitre 15, vous verrez comment dans certains cas des pointeurs “secrets” sont ajoutés à la structure, mais vous n'avez pas besoin de vous en soucier pour l'instant.

Vous pouvez déterminer la taille d'un struct en utilisant l'opérateur sizeof. Voici un petit exemple:

 
Sélectionnez
//: C04:Sizeof.cpp
// Taille des structures
#include "CLib.h"
#include "CppLib.h"
#include <iostream>
using namespace std;
 
struct A {
  int   i[100];
};
 
struct B {
  void f();
};
 
void B::f() {}
 
int main() {
  cout << "sizeof struct A = " << sizeof(A) << " bytes" << endl;
  cout << "sizeof struct B = " << sizeof(B) << " bytes" << endl;
  cout << "sizeof CStash in C = " << sizeof(CStash) << " bytes" << endl;
  cout << "sizeof Stash in C++ = " << sizeof(Stash) << " bytes" << endl;
} ///:~

Sur ma machine (vos résultats peuvent varier) le premier rapport d'impression donne 200 parce que chaque int occupe deux octets. struct B est une espèce d'anomalie parce que c'est un struct sans données membres. En C c'est illégal, mais en C++ nous avons besoin de pouvoir créer une structure dont la tache est d'étendre les noms de fonctions, et c'est donc autorisé. Dans tous les cas, le résultat produit par le deuxième rapport d'impression est une valeur non nulle un peu étonnante. Dans les premières versions du langage, la taille était zéro, mais une situation maladroite surgit quand vous créez de tels objets: Ils ont la même adresse que l'objet créé directement après eux, ils sont donc indistincts. Une des règles fondamentales des objets est que chaque objet a une adresse unique, ainsi les structures sans données membres ont toujours une taille minimale non nulle.

Les deux derniers sizeof vous montrent que la taille de la structure en C++ est la même que la taille de la version équivalente en C. Le C++ essaie de ne pas ajouter de suppléments inutiles.

4.7. L'étiquette d'un fichier d'en-tête

Lorsque vous créez une structure contenant des fonctions membres, vous êtes en train de créer un nouveau type de donnée. En général, vous voulez que ce type soit facilement accessible à vous-même et aux autres. Par ailleurs, vous désirez séparer l'interface (la déclaration) de l'implémentation (la définition des fonctions membres) de manière à ce que l'implémentation puisse être modifiée sans forcer une re-compilation du système entier. Vous y parvenez en plaçant les déclarations concernant votre nouveau type dans un fichier d'en-tête.

Lorsque j'ai d'abord appris à programmer en C, le fichier d'en-tête était un mystère pour moi. Beaucoup d'ouvrages sur le C ne semblent pas mettre l'accent dessus, et le compilateur n'imposait pas les déclarations de fonction, de telle manière que j'avais la plupart du temps l'impression que c'était optionnel, sauf quand des structures étaient déclarées. En C++, l'usage de fichiers d'en-tête devient clair comme de l'eau de roche. Ils sont obligatoires pour un développement de programme facile, et on y place des informations très spécifiques: les déclarations. Le fichier d'en-tête informe le compilateur de ce qui est disponible dans votre bibliothèque. Vous êtes en mesure d'utiliser la bibliothèque même si vous ne possédez que le fichier d'en-tête, ainsi que le fichier objet ou le fichier de bibliothèque. Vous n'avez pas besoin du code source du fichier cpp. Le fichier d'en-tête est l'endroit où est sauvegardé la spécification de l'interface.

Bien que ce ne soit pas imposé par le compilateur, la meilleure approche pour construire de grands projets en C est d'utiliser des bibliothèques; collecter des fonctions associées dans un même module objet ou bibliothèque, et utiliser un fichier d'en-tête pour contenir toutes les déclarations de fonctions. Cette pratique est de rigueur en C++. Vous pouviez placer n'importe quelle fonction dans une bibliothèque C, mais le type abstrait de donnée du C++ détermine les fonctions associées par leur accès commun aux données d'une même structure. N'importe quelle fonction membre doit être déclarée dans une déclaration de structure. Vous ne pouvez pas le faire ailleurs. L'usage de bibliothèques de fonctions était encouragé en C, mais institutionnalisé en C++.

4.7.1. L'importance des fichiers d'en-tête

Lorsque vous utilisez une fonction d'une bibliothèque, le langage C vous autorise à ignorer le fichier d'en-tête et à déclarer simplement les fonctions à la main. Dans le passé, certaines personnes procédaient ainsi afin d'accélérer un peu le travail du compilateur en lui épargnant la tâche d'ouvrir et d'inclure le fichier (ce n'est généralement pas un sujet de préoccupation avec les compilateurs modernes). Par exemple, voici une déclaration extrêmement nonchalante de la fonction C printf( )(de <stdio.h>):

 
Sélectionnez
printf(...);

Les ellipses spécifient une liste variable d'arguments (34), ce qui signifie: printf( ) reçoit certains arguments, chacun d'eux a un type, mais ignore cela. Prend tous les arguments que tu rencontres et accepte-les. En utilisant ce type de déclaration, vous mettez en veilleuse tout le système de vérification d'erreur sur les arguments.

Cette pratique peut entraîner des problèmes subtils. Si vous déclarer des fonctions à la main, dans un fichier, il est possible que vous fassiez une erreur. Puisque le compilateur ne voit dans ce fichier que la déclaration que vous avez faite à la main, il est capable de s'adapter à votre erreur. Ainsi, le programme se comportera correctement à l'édition des liens, mais l'usage de cette fonction dans le fichier en question sera erroné. C'est une erreur difficile à démasquer, et il est facile de l'éviter en utilisant un fichier d'en-tête.

Si vous placez toutes vos déclarations de fonctions dans un fichier d'en-tête, et que vous incluez ce fichier partout où vous utilisez la fonction, ainsi qu'à l'endroit où vous définissez la fonction, vous vous assurez d'une déclaration cohérente sur l'ensemble du système. Vous vous assurez également que la déclaration et la définition correspondent en incluant l'en-tête dans le fichier de définition.

Si une structure est déclarée dans un fichier d'en-tête en C++, vous devez inclure ce fichier d'en-tête partout où la structure en question est utilisée, et à l'endroit où sont définies les fonctions membres de cette structure. Le compilateur C++ retournera une erreur si vous essayez d'appeler une fonction régulière, ou d'appeler ou de définir une fonction membre, sans la déclarer auparavant. En forçant l'usage correct des fichiers d'en-tête, le langage assure la cohérence au sein des bibliothèques, et réduit le nombre de bugs en imposant l'utilisation de la même interface partout.

L'en-tête est un contrat entre vous et l'utilisateur de votre bibliothèque. Ce contrat décrit vos structures de données, les états des arguments et valeurs de retour pour l'appel des fonctions. Il dit: "Voici ce que ma bibliothèque fait." L'utilisateur a besoin de certaines de ces informations pour développer l'application et le compilateur a besoin de toutes les informations pour générer du code propre. L'utilisateur de la structure inclut simplement le fichier d'en-tête, crée des objets (instances) de cette structure, et lie avec le module objet ou la bibliothèque (c-à-d: le code compilé).

Le compilateur impose ce contrat en exigeant que vous déclariez toutes les structures et fonctions avant qu'elles ne soient utilisées et, dans le cas des fonctions membres, avant qu'elles ne soient définies. Ainsi, vous êtes forcés de placer les déclarations dans un fichier d'en-tête et d'inclure cet en-tête dans le fichier où les fonctions membres sont définies, et dans le(s) fichier(s) où elles sont utilisées. Parce qu'un fichier d'en-tête unique décrivant votre bibliothèque est inclus dans tout le système, le compilateur peut garantir la cohérence et éviter les erreurs.

Il y a certains enjeux que vous devez avoir à l'esprit pour organiser votre code proprement et écrire des fichiers d'en-tête efficaces. Le premier de ces enjeux concerne ce que vous pouvez mettre dans des fichiers d'en-tête. La règle de base est "uniquement des déclarations", c'est-à-dire seulement des informations destinées au compilateur, mais rien qui alloue de la mémoire en générant du code ou en créant des variables. La raison de cette limitation vient du fait qu'un fichier d'en-tête sera typiquement inclus dans plusieurs unités de compilation au sein d'un projet, et si de la mémoire est allouée pour un identifiant à plus d'un endroit, l'éditeur de liens retournera une erreur de définition multiple (il s'agit de la règle de la définition unique du C++: vous pouvez déclarer les choses autant de fois que vous voulez, mais il ne peut y avoir qu'une seule définition pour chaque chose).

Cette règle n'est pas complètement rigide. Si vous définissez une variable "statique" (dont la visibilité est limitée au fichier) dans un fichier d'en-tête, il y aurait de multiples instances de cette donnée à travers le projet, mais l'éditeur de liens ne subira aucune collision. (35). De manière générale, vous ne ferez rien dans un fichier d'en-tête qui entraînera une ambiguïté à l'édition des liens.

4.7.2. Le problème des déclarations multiples

Le deuxième enjeu relatif aux fichiers d'en-tête est le suivant: lorsque vous placez une déclaration de structure dans un fichier d'en-tête, il est possible que ce fichier soit inclus plus d'une fois dans un programme compliqué. Les flux d'entrées/sorties sont de bons exemples. A chaque fois qu'une structure fait des entrées/sorties, elle inclut un des fichiers d'en-tête iostream. Si le fichier cpp, sur lequel vous êtes en train de travailler, utilise plus qu'une sorte de structure (typiquement en incluant un fichier d'en-tête pour chacune d'elles), vous courez le risque d'inclure l'en-tête <iostream> plus d'une fois et de re-déclarer des flux d'entrées/sorties.

Le compilateur considère la redéclaration d'une structure (déclarée à l'aide du mot clé struct ou class) comme une erreur, puisque, dans le cas contraire, cela reviendrait à autoriser l'utilisation d'un même nom pour différents types. Afin d'éviter cette erreur lorsque de multiples fichiers d'en-tête sont inclus, vous avez besoin de doter vos fichiers d'en-tête d'une certaine intelligence en utilisant le préprocesseur (les fichiers d'en-tête standards du C++, comme <iostream> possèdent déjà cette "intelligence").

Aussi bien C que C++ vous autorisent à redéclarer une fonction, du moment que les deux déclarations correspondent, mais aucun des deux n'autorise la redéclaration d'une structure. En C++, cette règle est particulièrement importante, parce que si le compilateur vous autorisait à redéclarer une structure et que les deux déclarations différaient, laquelle des deux utiliserait-il?

Le problème de la redéclaration est d'autant plus important en C++, parce que chaque type de donnée (structure avec des fonctions) possède en général son propre fichier d'en-tête, et vous devez inclure un en-tête dans l'autre si vous voulez créer un autre type de donnée qui utilise le premier. Dans chaque fichier cpp de votre projet, il est probable que vous allez inclure plusieurs fichiers qui eux-mêmes incluent le même fichier d'en-tête. Au cours d'un processus de compilation donné, le compilateur est en mesure de rencontrer le même fichier d'en-tête à plusieurs reprises. A moins que vous fassiez quelque chose contre cela, le compilateur va voir la redéclaration de votre structure et reporter une erreur à la compilation. Afin de résoudre le problème, vous avez besoin d'en savoir un peu plus au sujet du préprocesseur.

4.7.3. Les directives #define, #ifdef et #endif du préprocesseur

La directive du préprocesseur #define peut être utilisée afin de créer des symboles à la compilation. Vous avez deux possibilités: vous pouvez simplement dire au préprocesseur que le symbole est défini, sans spécifier de valeur:

 
Sélectionnez
#define FLAG

ou alors vous pouvez lui donner une valeur (ce qui est la manière typique en C de définir une constante):

 
Sélectionnez
#define PI 3.14159

Dans chacun de cas, l'étiquette peut maintenant être testée par le préprocesseur afin de voir si elle est définie:

 
Sélectionnez
#ifdef FLAG

La valeur vrai sera retournée, et le code qui suit le #ifdef sera inclus dans le paquetage envoyé au compilateur. Cette inclusion s'arrête lorsque le préprocesseur rencontre l'instruction

 
Sélectionnez
#endif

ou

 
Sélectionnez
#endif // FLAG

Toute autre chose qu'un commentaire sur la même ligne, à la suite du #endif est illégal, même si certains compilateurs l'acceptent. La paire #ifdef/ #endif peut être imbriquée.

Le complément de #define est #undef(abréviation pour "un-define"), qui fera qu'une instruction #ifdef utilisant la même variable retournera le résultat faux. #undef entraînera également l'arrêt de l'usage d'une macro par le préprocesseur. Le complément de #ifdef est #ifndef, qui retourne vrai si l'étiquette n'a pas été définie (C'est l'instruction que nous allons utiliser pour les fichiers d'en-tête).

Il y a d'autres fonctionnalités utiles dans le préprocesseur du C. Vous devriez consulter votre documentation locale pour un tour d'horizon complet.

4.7.4. Un standard pour les fichiers d'en-tête

Dans chaque fichier d'en-tête qui contient une structure, vous devriez d'abord vérifier si l'en-tête a déjà été inclus dans le fichier cpp en question. Vous accomplissez cela en testant la définition d'un symbole du préprocesseur. Si le symbole n'est pas défini, le fichier n'a pas été inclus, vous devriez alors définir ce symbole (de telle manière que la structure ne puisse être redéclarée) puis déclarer la structure. Si le symbole a été défini, alors ce type a déjà été déclaré, et vous deviez simplement ignorer le code qui le déclare à nouveau. Voici à quoi devrait ressembler le fichier d'en-tête:

 
Sélectionnez

#ifndef HEADER_FLAG
#define HEADER_FLAG
// Ici vient la déclaration du type...
#endif // HEADER_FLAG

Comme vous pouvez le voir, la première fois que le fichier d'en-tête est inclus, le contenu de ce fichier (y compris votre déclaration de type) sera inclus par le préprocesseur. Toute inclusion subséquente dans une unité de compilation donnée verra la déclaration du type ignorée. Le nom HEADER_FLAG peut être n'importe quel nom unique, mais un standard fiable est de mettre le nom du fichier d'en-tête en lettres majuscules et de remplacer les points par des caractères de soulignement (les caractères de soulignement en tête du nom sont toutefois réservé aux noms du système). Voici un exemple:

 
Sélectionnez
//: C04:Simple.h
// Simple header that prevents re-definition
#ifndef SIMPLE_H
#define SIMPLE_H
 
struct Simple {
  int i,j,k;
  initialize() { i = j = k = 0; }
};
#endif // SIMPLE_H ///:~

Bien que le SIMPLE_H, après le #endif, soit commenté et ainsi ignoré du préprocesseur, il est utile à des fins de documentation.

Ces instructions du préprocesseur qui permettent de prévenir l'inclusion multiple sont souvent appelées des gardes d'inclusion.

4.7.5. Les espaces de nommage dans les fichiers d'en-tête

Vous noterez que des directives using sont présentes dans presque tous les fichier cpp de cet ouvrage, habituellement sous la forme:

 
Sélectionnez
using namespace std;

Puisque std est l'espace de nommage qui entoure l'ensemble de la bibliothèque standard du C++, cette instruction using autorise les noms de la bibliothèques standard du C++ à être utilisés sans qualification. Toutefois, vous ne verrez pratiquement jamais une directive using dans un fichier d'en-tête (du moins en dehors de toute portée). La raison de ce fait est que la directive using élimine la protection de cet espace de nommage, et ce jusqu'à la fin de l'unité de compilation courante. Si vous placez une directive using (en dehors de toute portée) dans un fichier d'en-tête, cela signifie que cette perte de "protection de l'espace de nommage" sera effective dans tout fichier incluant l'en-tête en question, souvent d'autres fichiers d'en-tête. Par conséquent, si vous commencez à placer des directives using dans les fichiers d'en-tête, il est très facile de finir par éliminer les espaces de nommage partout, et ainsi de neutraliser les effets bénéfiques apportés par ces espaces de nommage.

En résumé, ne placez pas de directives using dans des fichiers d'en-tête.

4.7.6. Utiliser des fichiers d'en-tête dans des projets

Lors de la construction d'un projet en C++, vous le créerez habituellement par rassemblement d'un grand nombre de types différents (structures de données avec fonctions associées). Vous placerez habituellement la déclaration pour chaque type ou pour un groupe de types associés dans des fichiers d'en-tête séparés, puis vous définirez les fonctions relatives à ce type dans une unité de traduction. Lorsque vous utilisez ce type, vous devrez inclure le fichier d'en-tête afin d'effectuer les déclarations proprement.

Parfois, cette façon de faire sera respectée dans ce livre, mais la plupart du temps les exemples seront très simples, de telle manière que tout - les déclaration de structures, les définitions de fonctions et la fonction main( )- peut se trouver dans un fichier unique. Toutefois, gardez à l'esprit qu'en pratique, vous utiliserez de préférence des fichiers séparés, ainsi que des fichiers d'en-tête.

4.8. Structures imbriquées

La commodité de sortir les noms de données et de fonctions de l'espace de nom global s'étend aux structures. Vous pouvez imbriquer une structure dans une autre, ce qui conserve les éléments associés ensemble. La syntaxe de la déclaration est celle à laquelle on peut s'attendre, comme vous pouvez le voir dans la structure suivante, qui implémente une pile classique au moyen d'une liste simplement chaînée de manière à ce qu'elle ne manque "jamais" de mémoire:

 
Sélectionnez
//: C04:Stack.h
// struct imbriquée dans une liste chaînée
#ifndef STACK_H
#define STACK_H
 
struct Stack {
  struct Link {
    void* data;
    Link* next;
    void initialize(void* dat, Link* nxt);
  }* head;
  void initialize();
  void push(void* dat);
  void* peek();
  void* pop();
  void cleanup();
};
#endif // STACK_H ///:~

Le struct imbriqué s'appelle Link, et il contient un pointeur sur le prochain Link dans la liste ainsi qu'un pointeur sur la donnée stockée dans le Link. Si le pointeur next vaut zéro, cela signifie que vous êtes à la fin de la liste.

Notez que le pointeur head est défini juste après la déclaration du struct Link, au lieu d'une définition séparée Link* head. C'est une syntaxe issue du C, mais cela souligne l'importance du point-virgule après la déclaration de structure; le point-virgule indique la fin de la liste des définitions pour ce type de structure. Les divers éléments de cette liste de définitions sont séparés par une virgule. (Généralement la liste est vide.)

La structure imbriquée possède sa propre fonction initialize( ), comme toutes les structures vues précédemment, pour assurer son initialisation correcte. Stack possède les deux fonctions initialize( ) et cleanup( ), ainsi que push( ), qui prend en paramètre un pointeur sur le data que vous voulez stocker (elle considère qu'il a été alloué sur le tas), et pop( ), qui retourne le pointeur de data se trouvant en haut de la pile avant de le supprimer du haut de la pile. (quand vous dépilez - pop( )- un élément, vous êtes responsable de la destruction de l'objet pointé par data.) La fonction peek( ) retourne également le pointeur de data se trouvant en haut de la pile, mais elle conserve cet élément en haut de la pile.

Voici les définitions des fonctions membres:

 
Sélectionnez
//: C04:Stack.cpp {O}
// Liste chaînée avec imbrication
#include "Stack.h"
#include "../require.h"
using namespace std;
 
void Stack::Link::initialize(void* dat, Link* nxt) {
  data = dat;
  next = nxt;
}
 
void Stack::initialize() { head = 0; }
 
void Stack::push(void* dat) {
  Link* newLink = new Link;
  newLink->initialize(dat, head);
  head = newLink;
}
 
void* Stack::peek() { 
  require(head != 0, "Pile 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;
}
 
void Stack::cleanup() {
  require(head == 0, "Pile non vide");
} ///:~

La première définition est particulièrement intéressante parce qu'elle vous montre comment définir un membre d'une structure imbriquée. Vous utilisez simplement un niveau supplémentaire de résolution de portée pour spécifier le nom du struct englobant. Stack::Link::initialize( ) prend les arguments et les assigne à ses membres.

Stack::initialize( ) met le pointeur head à zéro, ainsi l'objet sait que sa liste est vide.

Stack::push( ) prend l'argument, qui est un pointeur sur la variable dont vous voulez conserver la trace, et l'ajoute sur le haut de la pile. Pour cela, la fonction commence par utiliser new pour allouer la mémoire pour le Link que l'on va insérer au dessus. Puis elle appelle la fonction initialize( ) de Link pour assigner les valeurs appropriées aux membres de Link. Notez que le pointeur next est affecté au pointeur head courant; puis le pointeur head est affecté avec la valeur du nouveau pointeur Link. Cela pousse effectivement le Link en haut de la liste.

Stack::pop( ) capture le pointeur data situé sur le haut de la pile; puis fait descendre le pointeur head et supprime l'ancien sommet de la pile, et retourne enfin le pointeur capturé. Quand pop( ) supprime le dernier élément, alors le pointeur head vaut à nouveau zéro, ce qui signifie que la pile est vide.

Stack::cleanup( ) ne fait en fait aucun nettoyage. Au lieu de cela, il établit une politique ferme qui est que “vous (le programmeur client qui utilise cet objet Stack) êtes responsable du dépilement de tous les éléments du Stack et de leur suppression.” require( ) est utilisé pour indiquer qu'une erreur de programmation s'est produite si la pile n'est pas vide.

Pourquoi le destructeur de Stack ne pourrait-il pas être responsable de tous les objets que le programmeur client n'a pas dépilé ? Le problème est que Stack est en possession de pointeurs void, et vous apprendrez au Chapitre 13 qu'appeler delete sur un void* ne nettoie pas les choses proprement. Le fait de savoir “qui est responsable de la mémoire” n'est pas si simple, comme nous le verrons dans les prochains chapitres.

Voici un exemple pour tester la pile Stack:

 
Sélectionnez
//: C04:StackTest.cpp
//{L} Stack
//{T} StackTest.cpp
// Test d'une liste chaînée imbriquée
#include "Stack.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main(int argc, char* argv[]) {
  requireArgs(argc, 1); // Le nom du fichier est passé en argument
  ifstream in(argv[1]);
  assure(in,argv[1]);
  Stack textlines;
  textlines.initialize();
  string line;
  // Lit le fichier et stocke les lignes dans la pile:
    while(getline(in, line))
  textlines.push(new string(line));
  // Dépile les lignes de la pile et les affiche:
  string* s;
  while((s = (string*)textlines.pop()) != 0) {
    cout << *s << endl;
    delete s; 
  }
  textlines.cleanup();
} ///:~

Ceci est similaire à l'exemple précédent, mais on empile des lignes d'un fichier (sous forme de pointeur de string) sur le Stack puis on les dépile, ce qui provoque un affichage inversé du fichier à l'écran. Notez que la fonction membre pop( ) retourne un void* et que celui-ci doit être casté en string* avant de pouvoir être utilisé. Pour afficher le string à l'écran, le pointeur est déférencé.

Comme textlines est rempli, le contenu de line est “cloné” pour chaque push( ) en faisant un new string(line). La valeur retournée par l'expression new est un pointeur sur un nouveau string qui a été créé et qui a copié l'information dans line. Si vous aviez simplement passé l'adresse de line à push(), vous auriez obtenu un Stack rempli d'adresses identiques, pointant toutes vers line. Vous en apprendrez plus à propos de ce processus de “clonage” plus tard dans ce livre.

Le nom de fichier est récupéré depuis la ligne de commande. Pour s'assurer qu'il y ait assez d'arguments dans la ligne de commande, vous pouvez voir l'utilisation d'une deuxième fonction issue du fichier d'en-tête require.h: requireArgs( ), qui compare argc au nombre désiré d'arguments et affiche à l'écran un message d'erreur approprié avant de terminer le programme s'il n'y a pas assez d'arguments.

4.8.1. Résolution de portée globale

L'opérateur de résolution de portée vous sort des situations dans lesquelles le nom que le compilateur choisit par défaut (le nom “le plus proche”) n'est pas celui que vous voulez. Par exemple, supposez que vous ayiez une structure avec un identificateur local a, et que vous vouliez sélectionner un identificateur global a depuis l'intérieur d'une fonction membre. Le compilateur va par défaut choisir celui qui est local, et donc vous êtes obligé de lui dire de faire autrement. Quand vous voulez spécifier un nom global en utilisant la résolution de portée, vous utilisez l'opérateur avec rien devant. Voici un exemple qui montre une résolution de portée globale à la fois pour une variable et une fonction :

 
Sélectionnez
//: C04:Scoperes.cpp
// Résolution de portée globale
int a;
void f() {}
 
struct S {
  int a;
  void f();
};
 
void S::f() {
  ::f();  // autrement il y aurait récurrence!
  ::a++;  // Sélectionne le a global
  a--;    // Le a dans la portée du struct
}
 
int main() { S s; f(); } ///:~

Sans la résolution de portée dans S::f( ), le compilateur aurait par défaut sélectionné les versions membres de f( ) et a.

4.9. Résumé

Dans ce chapitre, vous avez appris le “tournant” fondamental du C++: vous pouvez mettre des fonctions dans les structures. Ce nouveau type de structure est appelé un type de données abstraites, et les variables que vous créez en utilisant ces structures sont appelés objets, ou instances, de ce type. Appeler une fonction membre d'un objet est appelé envoyer un message à cet objet. L'action première en programmation orientée objet est d'envoyer des messages aux objets.

Bien qu'empaqueter les données et les fonctions ensembles apporte un bénéfice considérable à l'organisation du code et simplifie l'utilisation des bibliothèques parce que cela empêche les conflits de noms en les cachant, vous pouvez faire beaucoup plus pour programmer de façon plus sûre en C++. Dans le prochain chapitre, vous apprendrez comment protéger certains membres d'un struct pour que vous soyez le seul à pouvoir les manipuler. Cela établit une frontière claire entre ce que l'utilisateur de la structure peut changer et ce que seul le programmeur peut changer.

4.10. 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. Au sein de la bibliothèque standard du langage C, la fonction puts() imprime un tableau de caractères sur la console (ainsi vous pouvez écrire puts("hello")). Ecrivez un programme C qui utilise puts() mais n'inclut pas < stdio.h> ou autrement dit déclarez la fonction. Compilez ce programme à l'aide de votre compilateur C. (Certains compilateurs C++ ne sont pas distincts de leur compilateur C; dans ce cas, vous devez rechercher une option à passer à la ligne de commande qui force une compilation C.) Maintenant, compilez-le avec un compilateur C++ et observez la différence.
  2. Créez une déclaration de structure avec une fonction membre unique, puis créez une définition pour cette fonction membre. Créez une instance de votre nouveau type de donnée, et appelez la fonction membre.
  3. Modifiez votre solution de l'exercice 2 de telle manière à déclarer la structure dans un fichier d'en-tête "protégé" de façon adéquate contre les inclusions multiples, avec la définition de la fonction dans un fichier cpp et votre main() dans un autre.
  4. Créez une structure contenant un membre donnée unique de type int, et deux fonctions globales prenant chacune en argument un pointeur sur cette structure. La première fonction prend un second argument de type int et affecte la valeur de cet argument au membre int de la structure, la seconde affiche la valeur du membre int de cette structure. Testez ces fonctions.
  5. Répétez l'exercice 4 mais déplacez les fonctions de manière à ce qu'elles soient des fonctions membres de la structure, et testez à nouveau ces fonctions.
  6. Créer une classe qui (de façon redondante) effectue la sélection d'un membre donnée ainsi que l'appel d'une fonction membre en utilisant le mot clé this(qui fait référence à l'adresse de l'objet courant).
  7. Créer un Stash qui contient des double s. Remplissez-le avec 25 valeurs de type double, puis affichez-les sur la console.
  8. Répétez l'exercice 7 avec Stack.
  9. Créer un fichier contenant une fonction f() qui prend un argument de type int et l'affiche sur la console en utilisant la fonction printf() déclarée dans < stdio.h> en écrivant: printf("%d\n", i)i est l'entier que vous désirez afficher. Créez un fichier séparé contenant main(), et dans ce fichier, déclarez f() comme prenant un argument de type float. Appelez f() depuis main(). Essayez de compiler et de lier votre programme à l'aide d'un compilateur C++ et observez ce qui se passe. Maintenant compilez et liez ce programme en utilisant un compilateur C, et regardez ce qui se passe lorsqu'il s'exécute. Expliquez les comportements observés.
  10. Trouvez comment produire du code assembleur à l'aide de vos compilateurs C et C++. Ecrivez une fonction en C et une structure avec une fonction membre unique en C++. Générez les codes assembleur correspondants et recherchez les noms de fonctions qui sont produits par votre fonction C et votre fonction membre C++, de telle manière que vous puissiez voir quelle décoration de nom est mise en oeuvre par le compilateur.
  11. Ecrivez un programme avec du code de compilation conditionnelle au sein de main(), de telle manière que lorsqu'une constante pré-processeur est définie, un message est affiché, alors qu'un autre message est affiché lorsqu'elle n'est pas définie. Compilez ce code en expérimentant avec un #define dans le programme, puis recherchez comment vous pouvez passer des définitions pré-processeur via la ligne de commande et expérimentez.
  12. Ecrivez un programme qui utilise assert() avec un argument qui est toujours faux (zéro) pour voir ce qui se passe lorsque vous l'exécutez. Maintenant, compilez-le avec #define NDEBUG et exécutez-le à nouveau pour voir la différence.
  13. Créez un type abstrait de donnée qui représente une cassette vidéo dans un étalage de location de vidéos. Essayez de considérer toutes les données et opérations qui peuvent être nécessaire au type Video pour se comporter de manière adéquate au sein du système de gestion de location de vidéos. Incluez une fonction membre print() qui affiche les informations concernant la Video.
  14. Créer un objet Stack pour contenir les objets Video de l'exercice 13. Créez plusieurs objets Video, stockez-les dans l'objet Stack, et affichez-les en utilisant Video::print().
  15. Ecrivez un programme qui affiche toutes les tailles des types de données fondamentaux sur votre ordinateur en utilisant sizeof.
  16. Modifiez Stash de manière à utiliser un vector<char> comme structure de donnée sous-jacente.
  17. Créez dynamiquement des emplacements mémoires pour les types suivants, en utilisant new: int, long, un tableau de 100 char s, un tableau de 100 float s. Affichez leurs adresses et puis libérez les espaces alloués à l'aide de delete.
  18. Ecrivez une fonction qui prend un char* en argument. En utilisant new, allouez dynamiquement un tableau de char s qui est de la taille du tableau de char s passé à la fonction. En utilisant l'itération sur les indices d'un tableau, copiez les caractères du tableau passé en argument vers celui alloué dynamiquement (n'oubliez pas le marqueur de fin de chaîne null) et retournez le pointeur sur la copie. Dans votre fonction main(), testez la fonction en y passant une constante chaîne de caractères statique entre guillemets, puis récupérez le résultat et passez-le à son tour à la fonction. Affichez les deux chaînes de caractères et les deux pointeurs de manière à vous rendre compte qu'ils correspondent à des emplacements mémoire différents. A l'aide de delete, nettoyez tout l'espace alloué dynamiquement.
  19. Montrez un exemple d'une structure déclarée à l'intérieur d'une autre structure (une structure imbriquée). Déclarez des membres données dans chacunes des structures, et déclarez et définissez des fonctions membres dans chacunes des structures. Ecrivez une fonction main() qui teste vos nouveaux types.
  20. Quelle est la taille d'une structure? Ecrivez un morceau de code qui affiche la taille de différentes structures. Créez des structures qui comportent seulement des données membres et d'autres qui ont des données membres ainsi que des fonctions membres. Puis, créez une structure qui n'a aucun membre du tout. Affichez toutes les tailles correspondantes. Expliquez le pourquoi du résultat obtenu pour la structure ne contenant aucun membre donnée du tout.
  21. C++ crée automatiquement l'équivalent d'un typedef pour les structures, comme vous l'avez appris dans ce chapitre. Il fait la même chose pour les énumérations et les unions. Ecrivez un petit programme qui démontre cela.
  22. Créez un Stack qui contient des Stash es. Chaque Stash contiendra 5 lignes d'un fichier passé en entrée. Créez les Stash es en utilisant new. Chargez le contenu d'un fichier dans votre Stack, puis réaffichez-le dans sa forme origninale en extrayant les données de la structure Stack.
  23. Modifiez l'exercice 22 de manière à créer une structure qui encapsule le Stack de Stashes es. L'utilisateur doit non seulement être en mesure d'ajouter et d'obtenir des lignes par l'intermédiaire de fonctions membres, mais sous le capot, la structure doit utiliser un Stack de Stash es.
  24. Créez une structure qui contient un int et un pointeur sur une autre instance de la même structure. Ecrivez une fonction qui prend l'adresse d'une telle structure et un int indiquant la longueur de la liste que vous désirez créer. Cette fonction créera une chaîne entière de ces structures ( une liste chaînée), en démarrant à la position indiquée par l'argument (la tête de la liste), chaque instance pointant sur la suivante. Créez les nouvelles structures en utilisant new, et placez le compte (de quel numéro d'objet il s'agit) dans le int. Dans la dernière structure de la liste, mettez une valeur de zéro dans le pointeur afin d'indiquer que c'est la fin. Ecrivez une seconde fonction qui prend en argument la tête de votre liste, et qui se déplace jusqu'à la fin en affichant la valeur du pointeur et la valeur du int pour chaque noeud de la chaîne.
  25. Répétez l'exercice 24, mais placez les fonctions à l'intérieur d'une structure au lieu d'avoir des structures "brutes" et des fonctions séparées.

précédentsommairesuivant
Ce terme peut prêter à polémique. Certains l'utilisent défini ainsi; d'autres l'utilisent pour décrire le contrôle d'accès, dont il est question dans le chapitre suivant.
Pour écrire la définition d'une fonction qui reçoit une liste variable d'arguments, vous devez utiliser varargs, bien que cette pratique doive être évitée en C++. Vous pouvez trouver des détails au sujet de varargs dans votre manuel C
En C++ standard, le mot clé static destiné à limiter la portée au fichier est une fonctionnalité dépréciée.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

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