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

Penser en C++

Volume 1


précédentsommairesuivant

XII. Références et le constructeur de copie

Les références sont comme des pointeurs constants qui sont automatiquement déréférencés par le compilateur.

Bien que les références existent aussi en Pascal, la version C++ est issue du langage Algol. Ils sont essentiels en C++ pour maintenir la syntaxe de la surcharge d'opérateur (voir le chapitre 12), mais ils sont aussi une convenance générale pour contrôler les arguments qui sont passés à l'intérieur et à l'extérieur des fonctions.

Ce chapitre regardera d'abord brièvement la différence entre les pointeurs en C et C++, puis introduira les références. Mais la majeure partie du chapitre fouillera dans une notion un peu confuse pour les programmeurs C++ novices : le constructeur de copie, un constructeur spécial (les références sont requises) qui fabrique un nouvel objet à partir d'un objet existant du même type. Le constructeur de copie est utilisé par le compilateur pour donner et récupérer des objets par valeur à l'intérieur et à l'extérieur des fonctions.

Finalement, les notions de pointeur sur membre qui sont obscures en C++ seront éclairées.

XII-A. Les pointeurs en C++

La différence la plus importante entre les pointeurs en C et en C++ est que C++ est un langage plus fortement typé. Ceci ressort quand void* est concerné. Le C ne vous laisse pas négligemment assigner un pointeur d'un type à un autre, mais il vous permet de fait de la réaliser à l'aide d'un void*. Ainsi,

 
Sélectionnez
bird* b;
rock* r;
void* v;
v = r;
b = v;

Comme cette particularité du C vous permet de traiter tranquillement n'importe quel type comme n'importe quel autre, elle crée une faille dans le système des types. Le C++ ne le permet pas ; le compilateur émet un message d'erreur, et si vous voulez vraiment traiter un type comme s'il en était un autre, vous devez le rendre explicite, au compilateur comme au lecteur, en utilisant le transtypage. (Le chapitre 3 a introduit la syntaxe “explicite” améliorée de transtypage du C++.)

XII-B. Les références en C++

Une référence( &) est comme un pointeur constant qui est automatiquement déréférencé. Elles sont généralement utilisées pour les listes d'arguments des fonctions et les valeurs retournées par celles-ci. Mais vous pouvez également créer une référence autonome. Par exemple,

 
Sélectionnez
//: C11:FreeStandingReferences.cpp
#include <iostream>
using namespace std;
 
// Référence ordinaire autonome :
int y;
int& r = y;
// Quand une référence est créée, elle doit
// être initialisée vers un objet vivant. 
// Toutefois, vous pouvez aussi dire :
const int& q = 12;  // (1)
// Les références sont liées au stockage de quelqu'un d'autre :
int x = 0;          // (2)
int& a = x;         // (3)
int main() {
  cout << "x = " << x << ", a = " << a << endl;
  a++;
  cout << "x = " << x << ", a = " << a << endl;
} ///:~

En ligne (1), le compilateur alloue un espace de stockage, l'initialise avec la valeur 12, et lie la référence à cette espace de stockage. Toute la question est que toute référence doit être liée à l'espace de stockage de quelqu'un d'autre. Quand vous accédez à une référence, vous accédez à cet espace. Ainsi, si vous écrivez des lignes comme les lignes (2) et (3), alors incrémenter a revient à incrémenter x, comme le montre le main( ). Une fois encore, la manière la plus facile de se représenter une référence est un pointeur sophistiqué. Un avantage de ce “pointeur” est que vous n'avez jamais à vous demander s'il a été initialisé (le compilateur l'impose) et comment le déréférencer (le compilateur le fait).

Il y a certaines règles quand on utilise des références :

  1. Une référence doit être initialisée quand elle est créée. (Les pointeurs peuvent être initialisés n'importe quand.)
  2. Une fois qu'une référence a été initialisée vers un objet, elle ne peut être modifiée afin de pointer vers un autre objet. (Les pointeurs peuvent pointer vers un autre objet à tout moment.)
  3. Vous ne pouvez pas avoir de références NULLES. Vous devez toujours pouvoir supposer qu'une référence est connectée à un espace de stockage légitime.

XII-B-1. Les références dans les fonctions

L'endroit le plus courant où vous verrez des références est dans les arguments de fonctions et les valeurs de retour. Quand une référence est utilisée comme argument de fonction, toute modification de la référence dans la fonction causera des changements à l'argument en dehors de la fonction. Bien sûr, vous pourriez faire la même chose en passant un pointeur, mais une référence a une syntaxe beaucoup plus propre. (Si vous voulez, vous pouvez considérer une référence comme une simple commodité de syntaxe.)

Si vous renvoyez une référence depuis une fonction, vous devez prendre les mêmes soins que si vous retourniez un pointeur depuis une fonction. Quel que soit l'élément vers lequel pointe une référence, il ne devrait pas disparaître quand la fonction effectue son retour, autrement vous feriez référence à un espace de mémoire inconnu.

Voici un exemple :

 
Sélectionnez
//: C11:Reference.cpp
// Références simples en C++
 
int* f(int* x) {
  (*x)++;
  return x; // Sûr, x est en dehors de cette portée
}
 
int& g(int& x) {
  x++; // Même effet que dans f()
  return x; // Sûr, en dehors de cette portée
}
 
int& h() {
  int q;
//!  return q;  // Erreur
  static int x;
  return x; // Sûr, x vit en dehors de cette portée
}
 
int main() {
  int a = 0;
  f(&a); // Moche (mais explicite)
  g(a);  // Propre (mais caché)
} ///:~

L'appel à f( ) n'a pas la commodité ni la propreté de l'utilisation de références, mais il est clair qu'une adresse est passée. Dans l'appel à g( ), une adresse est passée (par référence), mais vous ne le voyez pas.

Références const

L'argument référence dans Reference.cpp fonctionne seulement quand l'argument n'est pas un objet const. Si c'est un objet const, la fonction g( ) n'acceptera pas l'argument, ce qui est en fait une bonne chose, parce que la fonction modifie réellement l'argument externe. Si vous savez que la fonction respectera la const ance d'un objet, faire de l'argument une référence const permettra à la fonction d'être utilisée dans toutes les situations. Ceci signifie que, pour les types prédéfinis, la fonction ne modifiera pas l'argument, et pour les types définis par l'utilisateur, la fonction appellera uniquement des fonctions membres const, et ne modifiera aucune donnée membre public.

L'utilisation de références const dans les arguments de fonctions est particulièrement importante parce que votre fonction peut recevoir un objet temporaire. Il a pu être créé comme valeur retour d'une autre fonction ou bien explicitement par l'utilisateur de votre fonction. Les objets temporaires sont toujours const, donc si vous n'utilisez pas une référence const, cet argument ne sera pas accepté par le compilateur. Comme exemple très simple,

 
Sélectionnez
//: C11:ConstReferenceArguments.cpp
// Passer une référence comme const
 
void f(int&) {}
void g(const int&) {}
 
int main() {
//!  f(1); // Erreur
  g(1);
} ///:~

L'appel à f(1) provoque une erreur de compilation parce que le compilateur doit d'abord créer une référence. Il le fait en allouant du stockage pour un int, en l'initialisant à un et en produisant l'adresse pour le lier à la référence. Le stockage doit être un const parce que le modifier n'aurait aucun sens – vous ne pourrez jamais l'atteindre à nouveau. Avec tous les objets temporaires, vous devez faire la même supposition : qu'ils sont inaccessibles. Cela vaut la peine pour le compilateur de vous dire quand vous modifiez de telles données parce que le résultat serait une perte d'information.

Les références vers pointeur

En C, si vous voulez modifier le contenu du pointeur plutôt que ce vers quoi il pointe, votre déclaration de fonction ressemble à :

 
Sélectionnez
void f(int**);

et vous aurez à prendre l'adresse du pointeur quand vous le passez :

 
Sélectionnez
int i = 47;
int* ip = &amp;i;
f(&amp;ip);

Avec les références en C++, la syntaxe est plus propre. L'argument de la fonction devient une référence vers un pointeur, et vous n'avez plus besoin de prendre l'adresse de ce pointeur. Ainsi,

 
Sélectionnez
//: C11:ReferenceToPointer.cpp
#include <iostream>
using namespace std;
 
void increment(int*& i) { i++; }
 
int main() {
  int* i = 0;
  cout << "i = " << i << endl;
  increment(i);
  cout << "i = " << i << endl;
} ///:~

En exécutant ce programme, vous verrez par vous-même que le pointeur est décrémenté, pas ce vers quoi il pointe.

XII-B-2. Indications sur le passage d'arguments

Votre habitude quand vous passez un argument à une fonction devrait être de le passer par référence const. Bien qu'à première vue celui puisse ne paraître que comme une question d'efficacité (et vous n'avez normalement pas à vous inquiéter de réglages d'efficacité tant que vous concevez et assemblez votre programme), il y a plus en jeu : comme vous le verrez dans la suite du chapitre, un constructeur de copie est requis pour passer un objet par valeur, et il n'est pas toujours disponible.

Les économies d'efficacité peuvent être substantiels pour une habitude aussi simple : passer un argument par valeur requiert un appel à un constructeur et un destructeur, mais si vous n'allez pas modifier l'argument alors le passer par référence const nécessite seulement qu'une adresse soit poussée sur la pile.

En fait, pratiquement, la seule fois où passer une adresse n'est pas préférable est quand vous allez faire de tels dommages à un objet que le passer par valeur est la seule approche sûre (plutôt que de modifier l'objet extérieur, quelque chose que l'appelant n'attend pas en général). C'est le sujet de la section suivante.

XII-C. Le constructeur par recopie

Maintenant que vous comprenez les notions de base des références en C++, vous êtes prêts à aborder l'un des concepts les plus déroutants du langage : le constructeur par recopie, souvent appelé X(X&)(“X de ref de X”). Ce constructeur est essentiel pour le contrôle du passage et du renvoi par valeur de types définis par l'utilisateur lors des appels de fonction. Il est tellement important, en fait, que le compilateur synthétisera automatiquement un constructeur par recopie si vous n'en fournissez pas un vous-même, comme nous allons le voir.

XII-C-1. Passer & renvoyer par valeur

Pour comprendre la nécessité du constructeur par recopie, considérez la façon dont le C gère le passage et le retour de variables par valeur lors des appels de fonction. Si vous déclarez une fonction et effectuez un appel à celle-ci,

 
Sélectionnez
int f(int x, char c);
int g = f(a, b);

comment le compilateur sait-il comment passer et renvoyer ces variables ? Il le sait, tout simplement ! La variété des types avec laquelle il doit travailler est si petite – char, int, float, double, et leurs variations – que cette information est prédéfinie dans le compilateur.

Si vous devinez comment générer du code assembleur avec votre compilateur et déterminez les instructions générées par l'appel à la fonction f( ), vous obtiendrez l'équivalent de :

 
Sélectionnez
push  b
push  a
call  f()
add  sp,4
mov  g, register a

Ce code a été significativement nettoyé pour le rendre général ; les expressions pour b et a seront différentes selon que les variables sont globales (auquel cas elles seront _b et _a) ou locales (le compilateur les indexera avec le pointeur de pile). Ceci est également vrai de l'expression pour g. L'allure de l'appel à f( ) dépendra de votre schéma de décoration des noms, et “register a” dépend de la façon dont les registres du processeur sont nommés dans votre assembleur. La logique sous-jacente au code, toutefois, restera la même.

En C et C++, les arguments sont tout d'abord poussés sur la pile de la droite vers la gauche, puis l'appel à la fonction est effectué. Le code appelant est responsable du nettoyage des arguments sur la pile (ce qui explique le add sp,4). Mais remarquez que pour passer les arguments par valeur, le compilateur pousse simplement des copies sur la pile – il connait leur taille et sait que pousser ces arguments en réalise des copies fidèles.

La valeur de retour de f( ) est placée dans un registre. Encore une fois, le compilateur sait tout ce qu'il faut savoir à propos du type de la valeur à retourner parce que ce type est prédéfini dans le langage, si bien que le compilateur peut le retourner en le plaçant dans un registre. Avec les types de données primitifs en C, la simple action de recopier les bits de la valeur est équivalente à copier l'objet.

Passer & retourner de grands objets

Mais à présent, considérez les types définis par l'utilisateur. Si vous créez une classe et que vous voulez passer un objet de cette classe par valeur, comment le compilateur est-il supposé savoir ce qu'il faut faire ? Ce n'est pas un type prédéfini dans le compilateur ; c'est un type que vous avez créé.

Pour étudier cela, vous pouvez commencer avec une structure simple, qui est clairement trop large pour revenir via les registres :

 
Sélectionnez
//: C11:PassingBigStructures.cpp
struct Big {
  char buf[100];
  int i;
  long d;
} B, B2;
 
Big bigfun(Big b) {
  b.i = 100; // Fait quelque chose à l'argument
  return b;
}
 
int main() {
  B2 = bigfun(B);
} ///:~

Décoder le code assembleur résultant est un petit peu plus compliqué ici parce que la plupart des compilateurs utilisent des fonctions “assistantes” plutôt que de mettre toutes les fonctionnalités inline. Dans main( ), l'appel à bigfun( ) commence comme vous pouvez le deviner – le contenu entier de B est poussé sur la pile. (Ici, vous pouvez voir certains compilateurs charger les registres avec l'adresse du Big et sa taille, puis appeler une fonction assistante pour pousser le Big sur la pile.)

Dans le fragment de code précédent, pousser les arguments sur la pile était tout ce qui était requis avant de faire l'appel de fonction. Dans PassingBigStructures.cpp, toutefois, vous verrez une action supplémentaire : l'adresse de B2 est poussée avant de faire l'appel, même si ce n'est de toute évidence pas un argument. Pour comprendre ce qu'il se passe ici, vous devez saisir les contraintes qui s'exercent sur le compilateur quand il fait un appel de fonction.

Cadre de pile de l'appel de fonction

Quand le compilateur génère du code pour un appel de fonction, il pousse d'abord tous les arguments sur la pile, puis fait l'appel. Dans la fonction, du code est généré pour déplacer le pointeur sur la pile encore plus bas pour fournir un espace de stockage pour les variables locales. (“Bas” est relatif ici ; votre machine peut aussi bien incrémenter que décrémenter le pointeur sur la pile pendant un push.) Mais pendant le CALL en langage assembleur, le processeur pousse l'adresse dans le code programme d'où provenait l'appel à la fonction, afin que le RETURN en langage assembleur puisse utiliser cette adresse pour revenir à l'endroit de l'appel. Cette adresse est bien sûr sacrée, parce que sans elle votre programme sera complètement perdu. Voici à quoi ressemble la cadre de pile après le CALL et l'allocation de l'emplacement pour les variables locales dans la fonction :

Image non disponible

Le code généré pour le reste de la fonction s'attend à ce que la mémoire soit disposée exactement de cette façon, afin qu'il puisse délicatement choisir parmi les arguments de la fonction et les variables locales, sans toucher l'adresse de retour. J'appellerai ce bloc de mémoire, qui est tout ce qu'une fonction utilise dans le processus d'appel à la fonction, la cadre de fonction.

Vous pourriez penser raisonnable d'essayer de retourner les valeurs sur la pile. Le compilateur pourrait simplement les pousser, et la fonction pourrait retourner un offset pour indiquer où commence la valeur retour dans la pile.

Re-entrée

Le problème se produit parce que les fonctions en C et en C++ supportent les interruptions ; c'est-à-dire que les langages sont réentrants. Ils supportent également les appels de fonctions récursifs. Ceci signifie qu'à tout endroit de l'exécution d'un programme une interruption peut se produire sans ruiner le programme. Bien sûr, la personne qui écrit la routine de service d'interruption (ISR - pour interrupt service routine, ndt) est responsable de la sauvegarde et de la restauration de tous les registres qui sont utilisés dans l'ISR, mais si l'ISR a besoin d'utiliser de la mémoire plus bas dans la pile, elle doit pouvoir le faire sans risque. (Vous pouvez considérer une ISR comme une fonction ordinaire sans argument et une valeur de retour void qui sauve et restaure l'état du processeur. Un appel à une fonction ISR est provoqué par un événement hardware plutôt que par un appel explicite depuis un programme.)

À présent, imaginez ce qui se produirait si une fonction ordinaire essayait de retourner des valeurs sur la pile. Vous ne pouvez toucher aucune partie de la pile qui se trouve au-dessus de l'adresse de retour, donc la fonction devrait pousser les valeurs sous l'adresse de retour. Mais quand le RETURN en langage assembleur est exécuté, le pointeur de la pile doit pointer l'adresse de retour (ou juste en dessous, selon le type de machine). Ainsi juste avant le RETURN, la fonction doit déplacer le pointeur sur la pile vers le haut, nettoyant ainsi toutes ses variables locales. Si vous essayez de retourner des valeurs sur la pile sous l'adresse de retour, vous êtes vulnérable à ce moment parce qu'une interruption pourrait intervenir. L'ISR déplacerait le pointeur sur la pile vers le bas pour retenir son adresse de retour et ses variables locales et écraser votre valeur de retour.

Pour résoudre ce problème, l'appelant pourrait être responsable de l'allocation de stockage supplémentaire sur la pile pour les valeurs de retour avant d'appeler la fonction. Toutefois, le C n'a pas été conçu ainsi, et le C++ doit être compatible. Comme vous verrez dans peu de temps, le compilateur C++ utilise un procédé plus efficace.

Votre idée suivante pourrait être de retourner la valeur dans une zone de donnée globale, mais cela ne marche pas non plus. Réentrer signifie que n'importe quelle fonction peut être une routine d'interruption pour n'importe quelle autre, y compris la fonction dans laquelle vous vous trouvez à ce moment. Ainsi, si vous placez la valeur de retour dans une zone globale, vous pourriez revenir dans la même fonction, ce qui écraserait cette valeur de retour. La même logique s'applique à la récursion.

Le seul endroit sûr pour retourner les valeurs est dans les registres, si bien que vous vous retrouvez à nouveau confronté au problème de ce qu'il faut faire quand les registres ne sont pas assez grands pour contenir la valeur de retour. La réponse est de pousser l'adresse de la destination de la valeur de retour sur la pile comme l'un des arguments de la fonction, et laisser la fonction copier l'information de retour directement dans la destination. Ceci ne résout pas seulement tous les problèmes, c'est plus efficace. C'est aussi la raison pour laquelle, dans PassingBigStructures.cpp, le compilateur pousse l'adresse de B2 avant l'appel à bigfun( ) dans main( ). Si vous regardez le code assembleur résultant pour bigfun( ), vous pouvez voir qu'il attend cet argument caché et réalise la copie vers la destination dans la fonction.

Copie de bit versus initialisation

Jusque là, tout va bien. Il y a une procédure exploitable pour passer et retourner de grandes structures simples. Mais notez que tout ce dont vous disposez est un moyen de recopier les bits d'un endroit vers un autre, ce qui fonctionne certainement bien pour la manière primitive qu'a le C de considérer les variables. Mais en C++ les objets peuvent être beaucoup plus sophistiqués qu'un assemblage de bits ; ils ont une signification. Cette signification peut ne pas réagir correctement à la recopie de ses bits.

Considérez un exemple simple : une classe qui sait combien d'objets de son type existe à chaque instant. Depuis le chapitre 10, vous connaissez la façon de le faire en incluant une donnée membre static :

 
Sélectionnez
//: C11:HowMany.cpp
// Une classe qui compte ses objets
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany.out");
 
class HowMany {
  static int objectCount;
public:
  HowMany() { objectCount++; }
  static void print(const string& msg = "") {
    if(msg.size() != 0) out << msg << ": ";
    out << "objectCount = "
         << objectCount << endl;
  }
  ~HowMany() {
    objectCount--;
    print("~HowMany()");
  }
};
 
int HowMany::objectCount = 0;
 
// Passe et renvoie PAR VALEURS
HowMany f(HowMany x) {
  x.print("argument x dans f()");
  return x;
}
 
int main() {
  HowMany h;
  HowMany::print("après construction de h");
  HowMany h2 = f(h);
  HowMany::print("après appel de f()");
} ///:~

La classe HowMany contient un static int objectCount et une fonction membre static print( ) pour afficher la valeur de cet objectCount, accompagnée d'un argument message optionnel. Le constructeur incrémente le compte à chaque fois qu'un objet est créé, et le destructeur le décrémente.

La sortie, toutefois, n'est pas ce à quoi vous vous attendriez :

 
Sélectionnez
après construction de h: objectCount = 1
argument x dans f(): objectCount = 1
~HowMany(): objectCount = 0
après l'appel à f(): objectCount = 0
~HowMany(): objectCount = -1
~HowMany(): objectCount = -2

Après que h a été créé, le compte d'objets vaut un, ce qui est correct. Mais après l'appel à f( ) vous vous attendriez à avoir le compte des objets à deux, parce que h2 est maintenant également dans la portée. À la place, le compte vaut zéro, ce qui indique que quelque chose a terriblement échoué. Ceci confirmé par le fait que les deux destructeurs à la fin rendent le compte d'objets négatif, ce qui ne devrait jamais se produire.

Regardez l'endroit dans f( ), après que l'argument ait été passé par valeur. Ceci signifie que h, l'objet original, existe en dehors du cadre de fonction, et il y a un objet supplémentaire dans le cadre de fonction, qui est la copie qui a été passée par valeur. Toutefois, l'argument a été passé en utilisant la notion primitive de copie de bits du C, alors que la classe C++ HowMany requiert une vraie initialisation pour maintenir son intégrité, et la recopie de bit par défaut échoue donc à produire l'effet désiré.

Quand l'objet local sort de la portée à la fin de l'appel à f( ), le destructeur est appelé, et il décrémente objectCount, si bien qu'en dehors de la fonction objectCount vaut zéro. La création de h2 est également réalisée en utilisant une copie de bits, si bien que le constructeur n'est pas appelé ici non plus, et quand h et h2 sortent de la portée, leur destructeur rendent objectCount négatif.

XII-C-2. Construction par recopie

Le problème a lieu parce que le compilateur fait une supposition sur la manière de créer un nouvel objet à partir d'un objet existant. Quand vous passez un objet par valeur, vous créez un nouvel objet, l'objet passé dans le cadre de fonction, à partir d'un objet existant, l'objet original hors du cadre de fonction. C'est également souvent vrai quand un objet est renvoyé par une fonction. Dans l'expression

 
Sélectionnez
HowMany h2 = f(h);

h2, un objet qui n'a pas été construit auparavant, est créé à partir de la valeur de retour de f( ), si bien qu'un nouvel objet est encore créé à partir d'un autre existant.

La supposition du compilateur est que vous voulez réaliser cette création par recopie de bits, et dans bien des cas cela peut fonctionner correctement, mais dans HowMany ce n'est pas le cas parce que la signification de l'initialisation va au-delà d'une simple recopie. Un autre exemple habituel a lieu si la classe contient des pointeurs – vers quoi pointent-ils, et devriez-vous les recopier ou bien devraient-ils être connectés à un nouvel espace mémoire ?

Heureusement, vous pouvez intervenir dans ce processus et empêcher le compilateur de faire une recopie de bits. Vous faites cela en définissant votre propre fonction à utiliser lorsque le compilateur doit faire un nouvel objet à partir d'un objet existant. En toute logique, vous créez un nouvel objet, donc cette fonction est un constructeur, et en toute logique également, le seul argument de ce constructeur à rapport à l'objet à partir duquel vous construisez. Mais cet objet ne peut être passé dans le constructeur par valeur, parce que vous essayez de définir la fonction qui gère le passage par valeur, et syntaxiquement cela n'a aucun sens de passer un pointeur parce que, après tout, vous créez le nouvel objet à partir d'un objet existant. Les références viennent ici à notre secours, donc vous prenez la référence de l'objet source. Cette fonction est appelée le constructeur par recopie et est souvent désigné X(X&), ce qui est sa forme pour une classe nommée X.

Si vous créez un constructeur par recopie, le compilateur ne fera pas une recopie de bits quand il créera un nouvel objet à partir d'un objet existant. Il appellera toujours votre constructeur par recopie. Donc, si vous ne créez pas un constructeur par recopie, le compilateur fera quelque chose de raisonnable, mais vous avez la possibilité de contrôler complètement le processus.

À présent, il est possible de résoudre le problème dans HowMany.cpp:

 
Sélectionnez
//: C11:HowMany2.cpp
// Le constructeur par recopie
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany2.out");
 
class HowMany2 {
  string name; // Identifiant d'objet
  static int objectCount;
public:
  HowMany2(const string& id = "") : name(id) {
    ++objectCount;
    print("HowMany2()");
  }
  ~HowMany2() {
    --objectCount;
    print("~HowMany2()");
  }
  // Le constructeur par recopie :
  HowMany2(const HowMany2& h) : name(h.name) {
    name += " copie";
    ++objectCount;
    print("HowMany2(const HowMany2&)");
  }
  void print(const string& msg = "") const {
    if(msg.size() != 0) 
      out << msg << endl;
    out << '\t' << name << ": "
        << "objectCount = "
        << objectCount << endl;
  }
};
 
int HowMany2::objectCount = 0;
 
// Passe et renvoie PAR VALEUR:
HowMany2 f(HowMany2 x) {
  x.print("argument x dans f()");
  out << "Retour de f()" << endl;
  return x;
}
 
int main() {
  HowMany2 h("h");
  out << "avant appel à f()" << endl;
  HowMany2 h2 = f(h);
  h2.print("h2 après appel à f()");
  out << "Appel de f(), pas de valeur de retour" << endl;
  f(h);
  out << "après appel à f()" << endl;
} ///:~

Il y a un certain nombre de nouvelles astuces dans ce code afin que vous puissiez avoir une meilleure idée de ce qu'il se produit. Tout d'abord, le string name agit comme un identifiant d'objet quand l'information concernant cet objet est affichée. Dans le constructeur, vous pouvez placer une chaine identifiante (généralement le nom de l'objet) qui est copiée vers name en utilisant le constructeur de string. Le = "" par défaut crée une string vide. Le constructeur incrémente l' objectCount comme précédemment, et le destructeur le décrémente.

Après cela vient le constructeur par recopie HowMany2(const HowMany2&). Il ne peut créer un nouvel objet qu'à partir d'un autre objet existant, si bien que le nom de l'objet existant est copié vers name, suivi par le mot “copie” afin que vous puissiez voir d'où il vient. Si vous regardez attentivement, vous verrez que l'appel name(h.name) dans la liste d'initialisation du constructeur appelle en fait le constructeur par recopie de string.

Dans le constructeur par recopie, le compte d'objet est incrémenté exactement comme dans le constructeur normal. Ceci signifie que vous aurez à présent un compte d'objet exact quand vous passez et retournez par valeur.

La fonction print( ) a été modifiée pour afficher un message, l'identifiant de l'objet, et le compte de l'objet. Elle doit à présent accéder à la donnée name d'un objet particulier, et ne peut donc plus être une fonction membre static.

Dans main( ), vous pouvez constater qu'un deuxième appel à f( ) a été ajouté. Toutefois, cet appel utilise l'approche commune en C qui consiste à ignorer la valeur retour. Mais maintenant que vous savez comment la valeur est retournée (c'est-à-dire que du code au sein de la fonction gère le processus de retour, plaçant le résultat dans une destination dont l'adresse est passée comme un argument caché), vous pouvez vous demander ce qu'il se passe quand la valeur retour est ignorée. La sortie du programme éclairera la question.

Avant de montrer la sortie, voici un petit programme qui utilise les iostreams pour ajouter des numéros de ligne à n'importe quel fichier :

 
Sélectionnez
//: C11:Linenum.cpp
//{T} Linenum.cpp
// Ajoute les numéros de ligne
#include "../require.h"
#include <vector>
#include <string>
#include <fstream>
#include <iostream>
#include <cmath>
using namespace std;
 
int main(int argc, char* argv[]) {
  requireArgs(argc, 1, "Usage: linenum file\n"
    "Ajoute des numéros de ligne à un fihier");
  ifstream in(argv[1]);
  assure(in, argv[1]);
  string line;
  vector<string> lines;
  while(getline(in, line)) // Lit le fichier entièrement
    lines.push_back(line);
  if(lines.size() == 0) return 0;
  int num = 0;
  // Le nombre de lignes dans le fichier détermine la valeur de width :
  const int width = 
    int(log10((double)lines.size())) + 1;
  for(int i = 0; i < lines.size(); i++) {
    cout.setf(ios::right, ios::adjustfield);
    cout.width(width);
    cout << ++num << ") " << lines[i] << endl;
  }
} ///:~

Le fichier entier est lu dans un vector<string>, utilisant le code que vous avez vu plus tôt dans le livre. Quand nous affichons les numéros de ligne, nous aimerions que toutes les lignes soient alignées, et ceci requiert d'ajuster le nombre de lignes dans le fichier afin que la largeur autorisée pour le nombre de lignes soit homogène. Nous pouvons facilement déterminer le nombre de lignes en utilisant vector::size( ), mais ce qu'il nous faut vraiment savoir est s'il y a plus de 10 lignes, 100 lignes, 1000 lignes etc. Si vous prenez le logarithme en base 10 du nombre de lignes dans le fichier, que vous le tronquez en int et ajoutez un à la valeur, vous trouverez la largeur maximum qu'aura votre compte de lignes.

Vous remarquerez une paire d'appels étranges dans la boucle for: setf( ) et width( ). Ce sont des appels ostream qui vous permettent de contrôler, dans ce cas, la justification et la largeur de la sortie. Toutefois, ils doivent être appelés à chaque fois qu'une ligne est sortie et c'est pourquoi ils se trouvent dans la boucle for. Le deuxième volume de ce livre contient un chapitre entier expliquant les iostreams qui vous en dira plus à propos de ces appels ainsi que d'autres moyens de contrôler les iostreams.

Quand Linenum.cpp est appliqué à HowMany2.out, cela donne

 
Sélectionnez
1) HowMany2()
 2)   h: objectCount = 1
 3) avant appel à f()
 4) HowMany2(const HowMany2&)
 5)   h copie: objectCount = 2
 6) argument x dans f()
 7)   h copie: objectCount = 2
 8) Retour de f()
 9) HowMany2(const HowMany2&)
10)   h copie copie: objectCount = 3
11) ~HowMany2()
12)   h copie: objectCount = 2
13) h2 après appel à f()
14)   h copie copie: objectCount = 2
15) appel à f(), pas de valeur de retour
16) HowMany2(const HowMany2&)
17)   h copie: objectCount = 3
18) argument x dans f()
19)   h copie: objectCount = 3
20) Retour de f()
21) HowMany2(const HowMany2&)
22)   h copie copie: objectCount = 4
23) ~HowMany2()
24)   h copie: objectCount = 3
25) ~HowMany2()
26)   h copie copie: objectCount = 2
27) après appel à f()
28) ~HowMany2()
29)   h copie copie: objectCount = 1
30) ~HowMany2()
31)   h: objectCount = 0

Comme vous pouvez vous y attendre, la première chose qui se produit est que le constructeur normal est appelé pour h, ce qui incrémente le compte d'objets à un. Mais ensuite, comme on entre dans f( ), le constructeur par recopie est tranquillement appelé par le compilateur pour réaliser le passage par valeur. Un nouvel objet est créé, qui est la copie de h(d'où le nom « h copie ») dans le cadre de fonction de f( ), si bien que le compte d'objet passe à deux, par la grâce du constructeur par recopie.

La ligne huit indique le début du retour de f( ). Mais avant que la variable locale « h copie » puisse être détruite (elle sort de la portée à la fin de la fonction), elle doit être copiée dans la valeur de retour, qui se trouve être h2. Un objet précédemment non construit ( h2) est créé à partir d'un objet existant (la variable locale dans f( )), donc bien sûr le constructeur par recopie est utilisé à nouveau ligne 9. À présent, le nom devient « h copie copie » pour l'identifiant de h2 parce qu'il est copié à partir de la copie qu'est l'objet local dans f( ). Après que l'objet soit retourné, mais avant la fin de la fonction, le compte d'objet passe temporairement à trois, mais ensuite l'objet local « h copie » est détruit. Après que l'appel à f( ) ne se termine ligne 13, il n'y a que deux objets, h et h2, et vous pouvez voir que h2 finit de fait comme « h copie copie ».

Objets temporaires

À la ligne 15 commence l'appel à f(h), ignorant cette fois-ci la valeur de retour. Vous pouvez voir ligne 16 que le constructeur par recopie est appelé exactement comme avant pour passer l'argument. Et, également comme auparavant, la ligne 21 montre que le constructeur par recopie est appelé pour la valeur de retour. Mais le constructeur par recopie doit disposer d'une adresse pour l'utiliser comme sa destination (un pointeur this). D'où provient cette adresse ?

Il s'avère que le compilateur peut créer un objet temporaire à chaque fois qu'il en a besoin pour évaluer proprement une expression. Dans ce cas il en crée un que vous ne voyez même pas pour agir comme la destination de la valeur de retour ignorée de f( ). La durée de vie de cet objet temporaire est aussi courte que possible afin que le paysage ne se retrouve pas encombré avec des objets temporaires attendant d'être détruits et occupant de précieuses ressources. Dans certains cas, le temporaire peut être immédiatement passé à une autre fonction, mais dans ce cas il n'est plus nécessaire après l'appel à la fonction, si bien que dès que la fonction se termine en appelant le destructeur pour l'objet local (lignes 23 et 24), l'objet temporaire est détruit (lignes 25 et 26).

Finalement, lignes 28-31, l'objet h2 est détruit, suivi par h, et le compte des objets revient correctement à zéro.

XII-C-3. Constructeur par recopie par défaut

Comme le constructeur par recopie implémente le passage et le retour par valeur, il est important que le compilateur en crée un pour vous dans le cas de structures simples – en fait, la même chose qu'il fait en C. Toutefois, tout ce que vous avez vu jusqu'ici est le comportement primitif par défaut : la copie de bits.

Quand des types plus complexes sont impliqués, le compilateur C++ créera encore automatiquement un constructeur par recopie si vous n'en faites pas un. À nouveau, toutefois, une copie de bit n'a aucun sens, parce qu'elle n'implémente pas nécessairement la bonne signification.

Voici un exemple pour montrer l'approche plus intelligente que prend le compilateur. Supposez que vous créez une classe composée d'objets de plusieurs classes existantes. On appelle cela, judicieusement, composition, et c'est une des manières de créer de nouvelles classes à partir de classes existantes. À présent, endossez le rôle d'un utilisateur naïf qui essaie de résoudre rapidement un problème en créant une nouvelle classe de cette façon. Vous ne connaissez rien au sujet des constructeurs par recopie, et vous n'en créez donc pas. L'exemple démontre ce que le compilateur fait en créant le constructeur par recopie par défaut pour votre nouvelle classe :

 
Sélectionnez
//: C11:DefaultCopyConstructor.cpp
// Création automatique du constructeur par recopie
#include <iostream>
#include <string>
using namespace std;
 
class WithCC { // Avec constructeur par recopie
public:
  // Constructeur par défaut explicite requis :
  WithCC() {}
  WithCC(const WithCC&) {
    cout << "WithCC(WithCC&)" << endl;
  }
};
 
class WoCC { // Sans constructeur par recopie
  string id;
public:
  WoCC(const string& ident = "") : id(ident) {}
  void print(const string& msg = "") const {
    if(msg.size() != 0) cout << msg << ": ";
    cout << id << endl;
  }
};
 
class Composite {
  WithCC withcc; // Objets embarqués 
  WoCC wocc;
public:
  Composite() : wocc("Composite()") {}
  void print(const string& msg = "") const {
    wocc.print(msg);
  }
};
 
int main() {
  Composite c;
  c.print("Contenu de c");
  cout << "Appel du constructeur par recopie de Composite"
       << endl;
  Composite c2 = c;  // Appelle le constructeur par recopie
  c2.print("Contenu de c2");
} ///:~

La classe WithCC contient un constructeur par recopie, qui annonce simplement qu'il a été appelé, et ceci révèle un problème intéressant. Dans la classe Composite, un objet WithCC est créé en utilisant un constructeur par défaut. S'il n'y avait pas de constructeur du tout dans WithCC, le compilateur créerait automatiquement un constructeur par défaut, qui ne ferait rien dans le cas présent. Toutefois, si vous ajoutez un constructeur par recopie, vous avez dit au compilateur que vous allez gérer la création du constructeur, si bien qu'il ne crée plus de constructeur par défaut pour vous et se plaindra à moins que vous ne créiez explicitement un constructeur par défaut comme cela a été fait pour WithCC.

La classe WoCC n'a pas de constructeur par recopie, mais son constructeur stockera un message dans une string interne qui peut être affiché en utilisant print( ). Ce constructeur est appelé explicitement dans la liste d'initialisation du constructeur de Composite(brièvement introduite au Chapitre 8 et couverte en détail au Chapitre 14). La raison en deviendra claire plus loin.

La classe Composite a des objets membres de WithCC et de WoCC(remarquez que l'objet inclus wocc est initialisé dans la liste d'initialisation du constructeur, comme il se doit), et pas de constructeur par recopie explicitement défini. Toutefois, dans main( ) un objet est créé en utilisant le constructeur par recopie dans la définition :

 
Sélectionnez
Composite c2 = c;

Le constructeur par recopie pour Composite est créé automatiquement par le compilateur, et la sortie du programme révèle la façon dont il est créé :

 
Sélectionnez
Contenu de c: Composite()
Appel du constructeur par recopie de Composite
WithCC(WithCC&)
Contenu de c2: Composite()

Pour créer un constructeur par recopie pour une classe qui utilise la composition (et l'héritage, qui est introduit au Chapitre 14), le compilateur appelle récursivement le constructeur par recopie de tous les objets membres et de toutes les classes de base. C'est-à-dire que si l'objet membre contient également un autre objet, son constructeur par recopie est aussi appelé. Dans le cas présent, le compilateur appelle le constructeur par recopie de WithCC. La sortie montre ce constructeur en train d'être appelé. Comme WoCC n'a pas de constructeur par recopie, le compilateur en crée un pour lui qui réalise simplement une copie de bits, et l'appelle dans le constructeur par recopie de Composite. L'appel à Composite::print( ) dans le main montre que cela se produit parce que le contenu de c2.wocc est identique au contenu de c.wocc. Le processus que suit le compilateur pour synthétiser un constructeur par recopie est appelé initialisation par les membres (memberwise initialisation ndt).

Il vaut toujours mieux créer votre propre constructeur par recopie au lieu de laisser le compilateur le faire pour vous. Cela garantit que vous le contôlerez.

XII-C-4. Alternatives à la construction par recopie

À ce stade votre esprit flotte peut-être, vous pouvez vous demander comment vous avez bien pu écrire une classe qui fonctionne sans connaitre le constructeur par recopie. Mais rappelez-vous : vous n'avez besoin d'un constructeur par recopie que si vous allez passer un objet de votre classe par valeur. Si cela ne se produit jamais, vous n'avez pas besoin d'un constructeur par recopie.

Éviter le passage par valeur

“Mais,” dites-vous, “si je ne fais pas de constructeur par recopie, le compilateur en créera un pour moi. Donc comment puis-je savoir qu'un objet ne sera jamais passé par valeur ?”

Il y a une technique simple pour éviter le passage par valeur : déclarer un constructeur par recopie private. Vous n'avez même pas besoin de créer une définition, à moins qu'une de vos fonctions membres ou une fonction friend ait besoin de réaliser un passage par valeur. Si l'utilisateur essaye de passer ou de retourner l'objet par valeur, le compilateur produit un message d'erreur parce que le constructeur par recopie est private. Il ne peut plus créer de constructeur par recopie par défaut parce que vous avez explicitement déclaré que vous vous occupiez de ce travail.

Voici un exemple :

 
Sélectionnez
//: C11:NoCopyConstruction.cpp
// Eviter la construction par recopie
 
class NoCC {
  int i;
  NoCC(const NoCC&); // Pas de définition
public:
  NoCC(int ii = 0) : i(ii) {}
};
 
void f(NoCC);
 
int main() {
  NoCC n;
//! f(n); // Erreur : constructeur par recopie appelé
//! NoCC n2 = n; // Erreur : c-c appelé
//! NoCC n3(n); // Erreur : c-c appelé
} ///:~

Remarquez l'utilisation de la forme plus générale

 
Sélectionnez
NoCC(const NoCC&amp;);

utilisant const.

Fonctions modifiant les objets extérieurs

La syntaxe de référence est plus agréable à utiliser que celle des pointeurs, cependant elle obscurcit le sens pour le lecteur. Par exemple, dans la bibliothèque iostreams une fonction surchargée de la fonction get( ) prend un char& comme argument, et tout l'enjeu de la fonction est de modifier son argument en insérant le résultat du get( ). Toutefois, quand vous lisez du code utilisant cette fonction ce n'est pas immédiatement évident pour vous que l'objet extérieur est modifié :

 
Sélectionnez
char c;
cin.get(c);

À la place, l'appel à la fonction ressemble à un passage par valeur, qui suggère que l'objet extérieur n'est pas modifié.

À cause de cela, il est probablement plus sûr d'un point de vue de la maintenance du code d'utiliser des pointeurs quand vous passez l'adresse d'un argument à modifier. Si vous passez toujours les adresses comme des références constsauf quand vous comptez modifier l'objet extérieur via l'adresse, auquel cas vous passez par pointeur non- const, alors votre code est beaucoup plus facile à suivre pour le lecteur.

XII-D. Pointeurs sur membre

Un pointeur est une variable qui contient l'adresse d'un emplacement. Vous pouvez changer ce que sélectionne un pointeur pendant l'exécution, et la destination du pointeur peut être des données ou une fonction. Le pointeur sur membre du C++ utilise ce même concept, à l'exception que ce qu'il sélectionne est un emplacement dans une classe. Le dilemme ici est que le pointeur a besoin d'une adresse, mais il n'y a pas d'“adresse” à l'intérieur d'une classe; sélectionner un membre d'une classe signifie un décalage dans cette classe. Vous ne pouvez pas produire une adresse effective jusqu'à ce que vous combiniez ce décalage avec le début de l'adresse d'un objet particulier. La syntaxe d'un pointeur sur un membre requiert que vous sélectionniez un objet au même moment que vous déréférencez le pointeur sur le membre.

Pour comprendre cette syntaxe, considérez une structure simple, avec un pointer sp et un objet so pour cette structure. Vous pouvez sélectionner les membres avec la syntaxe suivante :

 
Sélectionnez
//: C11:SimpleStructure.cpp
struct Simple { int a; };
int main() {
  Simple so, *sp = &so;
  sp->a;
  so.a;
} ///:~

Maintenant supposez que vous avez un pointeur ordinaire sur un entier, ip. Pour accéder à ce que ip pointe, vous déréférencez le pointeur avec une ‘ *':

 
Sélectionnez
*ip = 4;

Finalement, considérez ce qui arrive si vous avez un pointeur qui pointe sur quelque chose à l'intérieur d'une classe, même s’il représente en fait un décalagepar rapport à l'adresse de l'objet. Pour accéder à ce qui est pointé, vous devez le déréférencer avec *. Mais c'est un excentrage dans un objet, donc vous devriez aussi vous référer à cet objet particulier. Ainsi, l' * est combiné avec un objet déréférencé. Donc la nouvelle syntaxe devient –>* pour un pointeur sur un objet, et .* pour l'objet ou une référence, comme ceci :

 
Sélectionnez
objectPointer->*pointerToMember = 47;
object.*pointerToMember = 47;

Maintenant, quelle est la syntaxe pour définir un pointeur sur membre? Comme n'importe quel pointeur, vous devez dire de quel type est le pointeur, et vous utilisez une * dans la définition. La seule différence est que vous devez dire quelle classe d'objet est utilisée par ce pointeur sur membre. Bien sûr, ceci est accompli avec le nom de la classe et l'opérateur de résolution de portée. Ainsi,

 
Sélectionnez
int ObjectClass::*pointerToMember;

définit une variable pointeur sur membre appelé pointerToMember qui pointe sur n'importe quel int à l'intérieur de ObjectClass. Vous pouvez aussi initialiser le pointeur sur membre quand vous le définissez (ou à n'importe quel autre moment) :

 
Sélectionnez
int ObjectClass::*pointerToMember = &ObjectClass::a;

Il n'y a actuellement aucune “adresse” de ObjectClass::a parce que vous venez juste de référencer la classe et non pas un objet de la classe. Ainsi, &ObjectClass::a peut être utilisé seulement comme une syntaxe de pointeur sur membre.

Voici un exemple qui montre comment créer et utiliser des pointeurs sur des données de membre :

 
Sélectionnez
//: C11:PointerToMemberData.cpp
#include <iostream>
using namespace std;
 
class Data {
public:  
  int a, b, c; 
  void print() const {
    cout << "a = " << a << ", b = " << b
         << ", c = " << c << endl;
  }
};
 
int main() {
  Data d, *dp = &d;
  int Data::*pmInt = &Data::a;
  dp->*pmInt = 47;
  pmInt = &Data::b;
  d.*pmInt = 48;
  pmInt = &Data::c;
  dp->*pmInt = 49;
  dp->print();
} ///:~

Évidemment, c'est trop maladroit pour être utilisé n'importe où excepté pour des cas spéciaux (ce qui est exactement ce pour lequel ils ont été prévus).

Aussi, les pointeurs sur membre sont tout à fait limités : ils peuvent être assignés seulement à une localisation spécifique à l'intérieur d'une classe. Vous ne pouvez pas, par exemple, les incrémenter ou les comparer comme vous pourriez avec des pointeurs ordinaires.

XII-D-1. Fonctions

Un exercice similaire produit la syntaxe du pointeur sur membre pour les fonctions membre. Un pointeur sur une fonction (introduit à la fin du chapitre 3) est défini comme cela:

 
Sélectionnez
int (*fp)(float);

Les parenthèses autour de (*fp) sont nécessaires pour forcer le compilateur à évaluer proprement la définition. Faute de quoi, ceci semblerait être une fonction qui retourne un int*.

Les parenthèses jouent aussi un rôle important pour définir et utiliser des pointeurs sur fonction membre. Si vous avez une fonction à l'intérieur d'une classe, vous définissez un pointeur sur cette fonction membre en insérant le nom de la classe et un opérateur de résolution de portée dans une définition de pointeur d'une fonction ordinaire :

 
Sélectionnez
//: C11:PmemFunDefinition.cpp
class Simple2 { 
public: 
  int f(float) const { return 1; }
};
int (Simple2::*fp)(float) const;
int (Simple2::*fp2)(float) const = &Simple2::f;
int main() {
  fp = &Simple2::f;
} ///:~

Dans la définition pour fp2 vous pouvez voir qu'un pointeur sur fonction membre peut aussi être initialisé quand il est créé, ou à n'importe quel moment. Au contraire des fonctions non membres, le &n'est pas optionnel quand il prend l'adresse d'une fonction membre. Cependant, vous pouvez donner l'identificateur de fonction sans liste d'arguments parce que tout peut être résolu au moyen du nom de classe et de l'opérateur de résolution de portée.

Un exemple

L'intérêt d'un pointeur est que vous pouvez changer la valeur pointée au moment de l'exécution, ce qui produit une flexibilité importante dans votre programmation parce que à travers un pointeur vous pouvez sélectionner ou changer le comportement durant l'exécution. Un pointeur de membre n'est pas différent ; il vous permet de choisir un membre durant l'exécution. Typiquement, vos classes peuvent seulement avoir des fonctions membres avec une portée publique (d'habitude, les données membres sont considérées comme faisant partie de l'implémentation sous-jacente), donc l'exemple suivant sélectionne les fonctions membres durant l'exécution.

 
Sélectionnez
//: C11:PointerToMemberFunction.cpp
#include <iostream>
using namespace std;
 
class Widget {
public:
  void f(int) const { cout << "Widget::f()\n"; }
  void g(int) const { cout << "Widget::g()\n"; }
  void h(int) const { cout << "Widget::h()\n"; }
  void i(int) const { cout << "Widget::i()\n"; }
};
 
int main() {
  Widget w;
  Widget* wp = &w;
  void (Widget::*pmem)(int) const = &Widget::h;
  (w.*pmem)(1);
  (wp->*pmem)(2);
} ///:~

Bien sûr, ce n'est pas particulièrement raisonnable de s'attendre à ce que l'utilisateur occasionnel crée de telles expressions compliquées. Si l'utilisateur doit directement manipuler un pointeur sur membre, alors un typedef est préférable. Pour vraiment comprendre cela, vous pouvez utiliser le pointeur sur membre comme une part du mécanisme interne de l'implémentation. Le précédent exemple utilisait un pointeur sur membre à l'intérieur de la classe. Tout ce que l'utilisateur a besoin de faire c'est de passer un nombre dans la sélection d'une fonction. (47)

 
Sélectionnez
//: C11:PointerToMemberFunction2.cpp
#include <iostream>
using namespace std;
 
class Widget {
  void f(int) const { cout << "Widget::f()\n"; }
  void g(int) const { cout << "Widget::g()\n"; }
  void h(int) const { cout << "Widget::h()\n"; }
  void i(int) const { cout << "Widget::i()\n"; }
  enum { cnt = 4 };
  void (Widget::*fptr[cnt])(int) const;
public:
  Widget() {
    fptr[0] = &Widget::f; // Full spec required
    fptr[1] = &Widget::g;
    fptr[2] = &Widget::h;
    fptr[3] = &Widget::i;
  }
  void select(int i, int j) {
    if(i < 0 || i >= cnt) return;
    (this->*fptr[i])(j);
  }
  int count() { return cnt; }
};
 
int main() {
  Widget w;
  for(int i = 0; i < w.count(); i++)
    w.select(i, 47);
} ///:~

Dans l'interface de classe et dans le main( ), vous pouvez voir que l'implémentation entière, incluant les fonctions, a été cachée ailleurs. Le code doit même demander le count( ) des fonctions. De cette manière, l'implémenteur de la classe peut changer la quantité de fonctions dans l'implémentation sous-jacente sans affecter le code où la classe est utilisée.

L'initialisation du pointeur sur membre dans le constructeur peut sembler trop spécifiée. Ne pouvez-vous pas dire

 
Sélectionnez
fptr[1] = &amp;g;

parce que le nom g se trouve dans la fonction membre, qui est automatiquement dans la portée de la classe ? Le problème est que ce n'est pas conforme à la syntaxe du pointeur sur membre, qui est requise pour que chacun, spécialement le compilateur, puisse voir ce qui se passe. De même, quand le pointeur sur membre est déréférencé, il semble que

 
Sélectionnez
(this->*fptr[i])(j);

soit aussi trop spécifié ; this semble redondant. De nouveau, la syntaxe requise est qu'un pointeur sur membre est toujours lié à un objet quand il est déréférencié.

XII-E. Résumé

Les pointeurs en C++ sont presque identiques aux pointeurs en C, ce qui est une bonne chose. Autrement, beaucoup de code C ne serait pas correctement compilé en C++. Les seules erreurs de compilation que vous produirez ont lieu lors d'affectations dangereuses. Si, en fait, celles-ci sont intentionnelles, l'erreur de compilation peut être supprimée à l'aide d'un simple transtypage (explicite, qui plus est !).

Le C++ ajoute également les références venant de l'Algol et du Pascal, qui sont comme des pointeurs constants automatiquement déréférencés par le compilateur. Une référence contient une adresse, mais vous la traitez comme un objet. Les références sont essentielles pour une syntaxe propre avec surcharge d'opérateur (sujet du prochain chapitre), mais elles ajoutent également une commodité de syntaxe pour passer et renvoyer des objets pour les fonctions ordinaires.

Le constructeur-copie prend une référence à un objet existant du même type que son argument, et il est utilisé pour créer un nouvel objet à partir d'un existant. Le compilateur appelle automatiquement le constructeur-copie quand vous passez ou renvoyez un objet par valeur. Bien que le compilateur crée automatiquement un constructeur-copie pour vous, si vous pensez qu'il en faudra un pour votre classe, vous devriez toujours le définir vous-même pour garantir que le comportement correct aura lieu. Si vous ne voulez pas que l'objet soit passé ou renvoyé par valeur, vous devriez créer un constructeur-copie privé.

Les pointeurs-vers-membres ont la même fonctionnalité que les pointeurs ordinaires : vous pouvez choisir une région de stockage particulière (données ou fonctions) à l'exécution. Il se trouve que les pointeurs-vers-membres travaillent avec les membres d'une classe au lieu de travailler les données ou les fonctions globales. Vous obtenez la flexibilité de programmation qui vous permet de changer de comportement à l'exécution.

XII-F. Exercices

Les solutions à certains exercices peuvent être trouvées dans le document électronique The Thinking in C++ Annotated Solution Guide, disponible pour une somme modique sur www.BruceEckel.com.

  1. Transformez le fragment de code “bird & rock” au début de ce chapitre en un programme C (en utilisant des struct pour le type de données), et montrez que cela compile. Ensuite, essayez de le compiler avec le compilateur C++ et voyez ce qui se produit.
  2. Prenez les fragments de code au début de la section “Les références en C++” et placez-les dans un main( ). Ajoutez des instructions pour afficher ce qui convient pour que vous vous prouviez à vous-même que les références sont comme des pointeurs qui sont automatiquement déréférencés.
  3. Écrivez un programme dans lequel vous essayez de (1) Créer une référence qui ne soit pas initialisée à sa création. (2) Modifier une référence pour qu'elle pointe vers un autre objet après son initialisation. (3) Créer une référence NULL.
  4. Écrivez une fonction qui prend un pointeur comme argument, modifie ce vers quoi il pointe, puis renvoie la destination du pointeur en tant que référence.
  5. Créez une classe avec quelques fonctions membres, et faites-en l'objet qui soit pointé par l'argument de l'exercice 4. Faites du pointeur un const et faites de certaines des fonctions membres des const et montrez que vous ne pouvez appeler que ces fonctions membre const dans votre fonction. Faites de l'argument de votre fonction une référence au lieu d'un pointeur.
  6. Prenez les fragments de code au début de la section “Les références vers pointeur” et transformez-les en programme.
  7. Créez une fonction qui prenne en argument une référence vers un pointeur de pointeur et modifie cet argument. Dans main( ) appelez cette fonction.
  8. Créez une fonction qui prenne un char& comme argument et le modifie. Dans main( ), affichez une variable char, appelez votre fonction avec cette variable, et affichez-la encore pour vous prouvez à vous-même qu'elle a été modifiée. Comment cela affecte-t-il la lisibilité du code ?
  9. Écrivez une classe qui a une fonction membre const et une fonction membre non- const. Écrivez trois fonctions qui prennent un objet de cette classe comme argument ; la première le prend par valeur, la deuxième par référence, et la troisième par référence const. Dans les fonctions, essayez d'appeler les deux fonctions membres de votre classe et expliquez le résultat.
  10. (Un peu difficile) Écrivez une fonction simple qui prend un int comme argument, incrémente la valeur, et la retourne. Dans main( ), appelez votre fonction. À présent, trouvez comment votre compilateur génère du code assembleur et parcourez les instructions assembleur pour comprendre comment les arguments sont passés et retournés, et comment les variables locales sont indexées depuis la pile.
  11. Écrivez une fonction qui prenne comme argument un char, un int, un float, et un double. Générez le code assembleur avec votre compilateur et trouvez les instructions qui poussent les arguments sur la pile avant l'appel de fonction.
  12. Écrivez une fonction qui retourne un double. Générez le code assembleur et déterminez comment la valeur est retournée.
  13. Produisez le code assembleur pour PassingBigStructures.cpp. Parcourez-le et démystifiez la façon dont votre compilateur génère le code pour passer et retourner de grandes structures.
  14. Écrivez une fonction récursive simple qui décrémente son argument et retourne zéro si l'argument passe à zéro ou autrement s'appelle elle-même. Générez le code assembleur pour cette fonction et expliquez comment la façon dont le code assembleur est créé par le compilateur supporte la récursion.
  15. Écrivez du code pour prouver que le compilateur synthétise automatiquement un constructeur de copie si vous n'en créez pas un vous-même. Prouvez que le constructeur de copie synthétisé réalise une copie bit à bit des types primitifs et appelle le constructeur de copie des types définis par l'utilisateur.
  16. Écrivez une classe avec un constructeur de copie qui s'annonce dans cout. Maintenant, créez une fonction qui passe un objet de votre nouvelle classe par valeur et une autre qui crée un objet local de votre nouvelle classe et le retourne par valeur. Appelez ces fonctions pour montrer que le constructeur de copie est, de fait, discrètement appelé quand on passe et retourne des objets par valeur.
  17. Créez une classe qui contienne un double*. Le constructeur initialise le double* en appelant new double et affecte une valeur à l'espace résultant à partir de l'argument du constructeur. Le destructeur affiche la valeur qui est pointée, affecte cette valeur à -1, appelle delete pour cet espace de stockage, puis affecte le pointeur à zéro. Ensuite, créez une fonction qui prend un objet de votre classe par valeur, et appelez cette fonction dans le main( ). Que se passe-t-il ? Réglez le problème en écrivant un constructeur de copie.
  18. Créez une classe avec un constructeur qui ressemble à un constructeur de copie, mais qui possède un argument supplémentaire avec une valeur par défaut. Montrez qu'il est toujours utilisé comme constructeur de copie.
  19. Créez une classe avec un constructeur de copie qui s'annonce lui-même. Faites une deuxième classe contenant un objet membre de la première classe, mais ne créez pas de constructeur de copie. Montrez que le constructeur de copie synthétisé dans la deuxième classe appelle automatiquement le constructeur de copie de la première classe.
  20. Créez une classe très simple, et une fonction qui retourne un objet de cette classe par valeur. Créez une deuxième fonction qui prenne une référence vers un objet de votre classe. Appelez la première fonction comme argument de la première fonction, et montrez que la deuxième fonction doit utiliser une référence const comme argument.
  21. Créez une classe simple sans constructeur de copie, et une fonction simple qui prend un objet de cette classe par valeur. À présent, modifiez votre classe en ajoutant une déclaration private(uniquement) pour le constructeur de copie. Expliquez ce qu'il se passe lors de la compilation de votre fonction.
  22. Cet exercice crée une alternative à l'utilisation du constructeur de copie. Créez une classe X et déclarez (mais ne définissez pas) un constructeur de copie private. Faites une fonction membre const publique clone( ) qui retourne une copie de l'objet qui est créé en utilisant new. À présent, écrivez une fonction qui prenne comme argument un const X& et clone une copie locale qui peut être modifiée. L'inconvénient de cette approche est que vous êtes responsable de la destruction explicite de l'objet cloné (en utilisant delete) quand vous en avez fini avec lui.
  23. Expliquez ce qui ne va pas avec Mem.cpp et MemTest.cpp du chapitre 7. Corrigez le problème.
  24. Créez une classe contenant un double et une fonction print( ) qui affiche le double. Dans main( ), créez des pointeurs ves membres pour, à la fois, la donnée membre et la fonction membre de votre classe. Créez un objet de votre classe et un pointeur vers cet objet, et manipulez les deux éléments de la classe via vos pointeurs vers membres, en utilisant à la fois l'objet et le pointeur vers l'objet.
  25. Créez une classe contenant un tableau de int. Pouvez vous le parcourir en utilisant un pointeur vers membre ?
  26. Modifiez PmemFunDefinition.cpp en ajoutant une fonction membre surchargée f( )(vous pouvez déterminer la liste d'argument qui cause la surcharge). Ensuite, créez un deuxième pointeur vers membre, affectez-lui la version surchargée de f( ), et appelez la fonction via ce pointeur. Comment se déroule la résolution de la surcharge dans ce cas ?
  27. Commencez avec FunctionTable.cpp du Chapitre 3. Créez une classe qui contienne un vector de pointeurs vers fonctions, ainsi que des fonctions membres add( ) et remove( ) pour, respectivement, ajouter et enlever des pointeurs vers fonctions. Ajoutez une fonction run( ) qui se déplace dans le vector et appelle toutes les fonctions.
  28. Modifiez l'exercice 27 ci-dessus afin qu'il fonction avec des pointeurs vers fonctions membres à la place.

précédentsommairesuivant
Merci à Owen Mortensen pour cet exemple

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2005 Bruce Eckel. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.