Musée des horreurs – Pointeurs et adresses

Quelques raccourcis :

Ce qui suit liste quelques horreurs quant à la compréhension des pointeurs, des références, des adresses et de leur manipulation.

Entrée 00 – Lecture au premier degré de la consigne

La situation : supposons qu'on utilise le langage C ou le langage C++ et qu'on doive appeler le sous-programme valider_entier() ayant le prototype suivant :

using RESULTAT = long;
// ...
const RESULTAT SUCCES = 0,
               ECHEC  = -1;
//...
RESULTAT valider_entier(int n, bool *valide);

dont le rôle est de vérifier si n est valide selon certaines règles et dépose un booléen vrai (true) ou faux (false) là où pointe valide en fonction du résultat de cette validation. Une fonction du genre est typique des API commerciaux ou de services dans des systèmes client/ serveur.

En temps normal, on doit se fier à la documentation de ces fonctions pour les utiliser correctement, n'ayant pas accès au code. Pour vous aider à comprendre le problème, toutefois, voici un exemple d'implémentation possible pour valider_entier(), exemple qui présume que n est valide si elle est strictement positive :

RESULTAT valider_entier(int n, bool *valide)
{
   // on écrit true ou false là où pointe valide
   *valide = (n > 0);
   // peu importe la valeur de cette constante (probablement 0)
   return SUCCES;
}

L'horreur : lire au premier degré que le deuxième paramètre est un bool* et appeler la fonction comme suit (remarquez les caractères gras) :

Horreur 00.0
bool *b;
int n = 3;
RESULTAT resultat = valider_entier(n, b);

...ou, peut-être pire encore, comme suit (remarquez ici encore les caractères gras) :

Horreur 00.1
bool *b = false;
int n = 3;
RESULTAT resultat = valider_entier(n, b);

Pourquoi ceci apparaît dans le musée des horreurs : un pointeur comme b représente une adresse dans l'ordinateur. Une fonction qui s'attend à recevoir un pointeur en paramètre pour y écrire quelque chose s'attend à ce que le pointeur reçu soit l'adresse d'une donnée existante.

Dans Horreur 00.0, la variable b est un pointeur de bool (un bool*) non initialisé, ce qui signifie que son contenu est présumé aléatoire. Le compilateur, s'il est gentil, devrait nous avertir de l'utilisation de b avant qu'elle ne soit initialisée.

La fonction valider_entier() ira, en écrivant là où pointe b, écrire quelque part en mémoire. Où? Excellente question! Avec ce programme, si vous êtes chanceuse ou chanceux, le programme plantera et vous dépisterez le problème avant qu'il n'ait de réelles conséquences... mais si vous êtes malchanceuse ou malchanceux, le programme ne plantera pas tout de suite, et ne se mettra à causer de sérieux problèmes qu'une fois livré seulement.

Dans Horreur 00.1, la variable b est un pointeur de bool (un bool*) initialisé à zéro (0), du fait que la convention de C et de C++ est que zéro soit faux. C++ permet d'affecter 0 à un pointeur peu importe son type du fait que 0 est l'adresse nulle par convention (voir ici pour en savoir plus). Le compilateur risque donc de ne pas prévenir la programmeuse ou le programmeur d'une opération dangereuse à l'initialisation de b, celle-ci étant une erreur de logique.

Bien qu'aucun avertissement ne soit offert par le compilateur, l'usage C++ garantit que 0 soit une adresse illégale pour un programme. Si valider_entier() écrit à cette adresse, le programme plantera. Faut le prendre du bon côté : il est nettement préférable qu'un programme plante en période de test; en période d'exploitation, c'est l'horreur!

Le bon usage : comprendre que lorsqu'une fonction s'attend à recevoir l'adresse d'un booléen, il faut lui fournir une adresse où il y a un booléen. Éviter de se conformer bêtement à la syntaxe et viser à comprendre la portée de notre geste :

Entrée 00 – Usage en bonne et due forme 0
bool b;
int n = 3;
RESULTAT resultat = valider_entier(n, &b);

Ainsi, ayant en mains l'adresse du booléen b, la fonction valider_entier() écrira là où pointe cette adresse, c'est-à-dire dans la variable b, qui est un endroit connu, du bon type.

On peut aussi s'aider d'une temporaire si on éprouve un malaise avec la notation d'adresse :

Entrée 00 – Usage en bonne et due forme 0a
bool b;
bool *pb = &b;
int n = 3;
RESULTAT resultat = valider_entier(n, pb);

Toutefois, cette temporaire est superflue.

Question : est-ce que l'horreur relevée ici se limite aux booléens?

Réponse: bien sûr que non! Le booléen est un exemple relevé en classe, mais le problème en est un de compréhension du rôle d'un pointeur ou d'une adresse, et peut apparaître avec d'autres types primitfs de même qu'avec des objets.

Entrée 00a – Corollaire

Il arrive qu'on fasse une lecture primaire de l'horreur 00.0 ci-dessus mais qui ait moins de conséquences. Ainsi, pour se conformer au prototype de valider_entier() exigeant un pointeur de bool comme second paramètre, certains réaliseront le besoin d'avoir l'adresse d'un booléen mais n'auront pas le réflexe d'aller déclarer un booléen automatique (bool b;) et d'aller chercher son adresse par la suite (&b), préférant y aller d'une allocation dynamique de mémoire.

Horreur 00a.0
bool *b = new bool;
int n = 3;
RESULTAT resultat = valider_entier(n, b);
//...delete b;

Mon illustre collègue et ami Vincent Echelard me souligne qu'en plus, un bool* occupe plus d'espace en mémoire qu'un bool... Que de gaspillage!

Il y a au moins deux problèmes avec cette approche :

Sachant cela, même si Horreur 00a.0 est valide, ce n'est pas l'approche à privilégier.

Entrée 00b – Suite au corollaire

En 2008, j'ai vu passer cette perle :

Horreur 00b.0
void f(bool *);
bool g()
{
   bool *b = new bool; // beurk! (voir plus haut)
   f(b); // idem
   return b; // double, non, triple beurk!
}

Cette approche est terrible :

Les enfants, n'essayez pas cela à la maison...

Entrée 01 – Quand les ideés se mêlent

La situation : on veut initialiser une chaîne s de manière à ce que son contenu soit une chaîne vide. On a vu les pointeurs de caractères et on a vu le type std::string (peut-être aussi d'autres représentations de chaînes de caractères), on a vu les pointeurs et l'allocation dynamique de mémoire, et tout se bouscule dans notre tête.

L'horreur : mêler pointeur de std::string et pointeur de char :

Horreur 01.0
#include <string>
//...
void f()
{
   using std::string;
   string *s = "";
   // ...
}

Pourquoi ceci apparaît dans le musée des horreurs : faut faire un choix. Ou on veut un pointeur de std::string (peut-être pour créer dynamiquement un tableau de chaînes de caractères), ou on veut une std::string.

La conséquence de la déclaration Horreur 01.0 est de faire pointer un pointeur de std::string (car s, ici, est un pointeur vers un objet dont la classe est std::string, et non pas une std::string en tant que tel) vers un pointeur de caractères (un const char*, ce qui est le type du littéral "" en C++) . Il ne faut pas oublier qu'avec C++, comme avec C, chaque chaîne de caractères litérale ("allo", par exemple) est en fait un pointeur vers son premier élément, pas un objet à part entière. La compilation de cette instruction devrait générer une erreur, qui ne pourrait être éliminée qu'avec un reinterpret_cast (mais ce serait une erreur grave car le code résultant serait extrêmement dangereux!).

Le bon usage : si notre désir est d'avoir une std::string, alors la manière de procéder est de ne pas utiliser de pointeur du tout :

Entrée 01.0a – Déclaration en bonne et due forme d'une instance de std::string
#include <string>
//...
void f()
{
   using std::string;
   string s = ""; // ok
   // ...
}

Sachez toutefois que std::string étant une classe, on y trouve un constructeur par défaut, qui initialise l'objet construit de manière à ce qu'il représente une chaîne vide. Ainsi, sans conséquence adverse, on pourrait se limiter à écrire :

Entrée 01.0b – Déclaration en bonne et due forme d'une instance de std::string
#include <string>
//...
void f()
{
   using std::string;
   string s; // mieux!
   // ...
}

Dans une optique de généricité, ceci est encore mieux (car plus général si on remplace std::string par T)  :

Entrée 01.0c – Déclaration en bonne et due forme d'une instance de std::string
#include <string>
//...
void f()
{
   using std::string;
   string s = string(); // encore mieux!
   // ...
}

Si notre désir est d'avoir un tableau de std::string, alors il faut connaître le nombre d'éléments du tableau et allouer le tableau avec l'opérateur new, qu'il faudra prendre soin de bien détruire par la suite avec delete[] :

Entrée 01.0d – Déclaration en bonne et due forme d'un tableau de std::string
#include <string>
//...
void f (const int nelems)
{
   using std::string;
   if (nelems > 0)
   {
      string *s = new string[nelems];
      // ...
      delete [] s;
   }
}

En pratique, dans un tel cas, il est presque certain que le recours à un vecteur standard serait préférable (et de beaucoup) :

Entrée 01.0e – Déclaration en bonne et due forme d'un tableau de std::string
#include <string>
#include <vector>
//...
void f (const int nelems)
{
   using std::string;
   using std::vector;
   if (nelems > 0)
   {
      vector<string> v(nelems, string());
      // ...
   }
}

Si on ne veut qu'une seule instance de std::string, il est peu probable (sans être impossible) qu'on désire vraiment allouer cette instance dynamiquement. L'allocation dynamique de mémoire (new, new[], delete, delete[]) est un mécanisme utile, parfois nécessaire, mais coûteux en ressources et en temps à bien des égards (en plus d'entraîner des risques de perte de mémoire dans les langages qui ne sont pas appuyés par un moteur de collecte automatisée d'ordures).

Ainsi, le code suivant est possible, mais suspect (et probablement un mauvais choix, Entrée 01.0b étant plus simple et plus rapide) :

Entrée 01.0f – Valide, mais douteux (et probablement une mauvaise idée)
#include <string>
//...
void f()
{
   using std::string;
   string *s = new string;
   // ...
   delete s;
}

Dans ce cas, envisagez remplacer le pointeur brut par un pointeur intelligent.

Évidemment, l'idéal serait de ne pas allouer la std::string dynamiquement du tout.

Entrée 02 – Pointeur de tableau ou pointeur sur un élément?

La situation : on veut générer une classe Tableau représentant un tableau dont la taille est décidée à l'exécution (donc passée au constructeur du tableau).

Pour simplifier, nous présumerons qu'il s'agit d'un tableau d'entiers (int) et nous ne nous préoccuperons que de la paire constructeur paramétrique/ destructeur, et nous présumerons que la taille passée en paramètre au constructeur est valide.

L'horreur : mêler pointeur de tableau et pointeur sur un élément. Ainsi :

Horreur 02.0
class Tableau
{
   int *elems_;
   int nelems_;
   // ...
public:
   Tableau(const int n)
      : nelems_(n), elems_(new int[n])
   {
   }
   //...
   ~Tableau() throw()
   {
      for (int i = 0; i < nelems_; i++)
         delete &elems_[i];
   }
};

Si vous cherchez un peu sur mon site, vous trouverez plusieurs exemples de tableaux bien faits (et certains, dû aux cours que j'enseigne, de niveau intermédiaire, donc pas si mal mais incomplets... Prudence!).

Pourquoi ceci apparaît dans le musée des horreurs : on remarque ici qu'on confond deux idées importantes, qui ne sont pas déconnectées l'une de l'autre mais ne sont pas non plus deux facettes d'un même concept.

Pour créer dynamiquement un tableau d'un certain type T (ici, le type est int), il faut utiliser une variable dont le type est pointeur sur T (ici, pointeur sur int, ou int*). La raison est qu'un tableau C++ n'est rien de plus qu'un pointeur sur son premier élément. L'opérateur new[] alloue à l'exécution suffisamment d'espace pour entreposer de manière contiguë en mémoire un certain nombre d'éléments du même type (ici, pour allouer nelems_ éléments de type int), et appelle le constructeur par défaut de chacun de ces éléments s'ils sont des objets.

L'implémentation de l'opérateur new[] est responsable, peu importe comment elle s'y prend, de libérer lors d'une invocation de delete[] la mémoire utilisée pour le tableau.

L'horreur ici est que le type du tableau est int*, et que le type de l'adresse de chacun de ses éléments est aussi int* (puisqu'il s'agit d'un tableau de int, et que l'adresse d'un int est, par définition, de type int*). Le compilateur ne peut pas relever l'erreur dans le destructeur; tout programme utilisant la classe Tableau ci-dessus est destiné à avoir un comportement imprévisible – au mieux, ça va planter tout de suite.

Le bon usage : quand vient le temps de détruire un tableau alloué dynamiquement, il faut détruire le tableau, pas ses éléments de manière individuelle :

Entrée 02.0 – Bonne destruction d'un tableau alloué dynamiquement
class Tableau
{
   int *elems_;
   int nelems_;
   // ...
public:
   Tableau(const int n)
      : nelems_(n), elems_(new int[n])
   {
   }
   //...
   ~Tableau() throw()
   {
      delete [] elems_;
   }
};

Notez que l'inverse de l'opérateur new[] utilisé pour allouer un tableau est l'opérateur delete[], pas delete. En effet, delete sur un int* détruit un seul int, alors que delete[] examine la table où sont listés les tableaux alloués dynamiquement et détruit le tableau tout entier, une fois appelés successivement les destructeurs de tous ses éléments).

Entrée 03 – Confusion des genres (des fois, ça compile, mais c'est mortel!)

Merci à Pierre Prud'homme pour ce bijou de perversion!

La situation : il existe des cas limites qui, pour des raisons techniques, passent à la compilation mais sont dangereuses au point de faire planter solidement les programmes.

Le langage C définit un tableau comme une structure de données très primitive, soit une séquence contiguë en mémoire d'éléments de même type. Un tableau C n'est en fait rien de plus ou de moins qu'un pointeur sur le premier élément de cette séquence.

C++, qui est un langage orienté objet mais dont l'héritage C demeure visible, conserve cette définition de ce qu'est un tableau mais offre aussi, de par ses bibliothèques standard, des classes plus sophistiquées (comme std::vector ou std::string) qui sont à la fois rapides et mieux protégées. Dans la majorité des cas, vous devriez privilégier ces classes et éviter les abstractions plus primitives.

L'un des avantages des tableaux C est leur simplicité: aller au iième élément d'un tableau tab signifie aller à l'adresse tab+i (ou tab + i fois la taille d'un élément de tab, lorsque l'arithmétique est exprimée en bytes). Ainsi :

const int MAX = 100;
double tab[MAX];
// les deux boucles qui suivent sont précisément
// équivalentes
for (int i = 0; i < MAX; ++i)
   tab[i] = 0.5;
for (int i = 0; i < MAX; ++i)
   *(tab+i) = 0.5;
//
// Cette boucle-ci a le même effet mais devrait être légèrement plus rapide que les précédentes.
// Pouvez-vous dire la raison de cette possible (mais légère) différence de vitesse?
//
// Note: il existe une règle en  C et en C++ disant qu'on peut toujours vérifier l'adresse juste après un tableau
// (p.ex.: p!=&tab[MAX]) mais qu'il n'est pas, en général, légal d'utiliser ce qui s'y trouve. Par exemple,
// double d = tab[MAX]; est incorrect, de même que tab[MAX] = 1.0;)
//
for (double *p = &tab[0]; p != &tab[MAX]; ++p)
   *p = 0.5;
// variante équivalente
for (double *p = tab + 0; p != tab + MAX; ++p)
   *p = 0.5;
tab == &tab[0]
tab+i == &tab[i]
*(tab+i) == tab[i]
*tab == tab[0]

De manière générale, toutes les équivalences listées ci-dessus sont vraies. Les opérations sur des tableaux en C et en C++ sont équivalentes à de la simple arithmnétique de pointeurs.

Notez que nous ne nous intéressons pas ici aux tableaux à plus d'une dimension, mais vous pouvez lire cet article si la question vous intéresse.

L'arithmétique de pointeurs est ainsi faite que B+i, avec B un pointeur de type T et i un entier, signifie (en bytes) B+(i fois la taille d'un T). Ainsi, dans :

const int MAX = 100;
double tab[MAX];
*(tab+2) = 3.14159;

...l'écriture tab+2 signifie tab + (2 fois la taille d'un double) car tab est un pointeur de double.

Ainsi, si tab contient 1000 (donc si tab[0] se trouve à la case 1000 dans la mémoire du processus) et si sizeof(double)==8 (donc si un double occupe 8 bytes en mémoire – probablement 64 bits) alors tab+2 représente l'adresse 1016 (1000+2*8) et *(tab+2) signifie le double se trouvant à l'adresse 1016.

Le compilateur traitera *(tab+2) comme un double car tab est un double* et car les règles de l'arithmétique de pointeurs font aussi de tab+2 un double*. Ceci est vrai qu'il y ait un double ou non à cet endroit, ce qui fait que les opérations commentées par la mention boum! ci-dessous compilent toutes, mais sont toutes incorrectes :

const int MAX = 100;
double tab[MAX];
*(tab+101) = 3.14159; // boum!
*(tab-1) = 12.0; // boum!
//
// on dit au compilateur de nous faire confiance et de
// traiter &c, qui est un char*, comme un double*. Il
// se trouve que sizeof(char)==1 et sizeof(double)==8,
// ce qui fait qu'utiliser p par la suite est très, très
// périlleux...
//
char c = 'A';
double *p = reinterpret_cast<double*> (&c);
*p = 0.123456789; // boum!!!!!

Sachant ceci, si on a une classe X qui se présente comme suit :

// Fichier X.h
class X
{
   int val_;
public:
   X()
      : val_{}
   {
   }
   int valeur() const
      { return val_; }
   void valeur(int val)
      { val_ = val; }
};

... alors le programme suivant devrait créer 10 instances de X, avec des valeurs allant de 1 à 10 inclusivement, puis les afficher :

#include "X.h"
#include <iostream>
int main()
{
   using namespace std;
   const int NB_X = 10;
   X tab[NB_X];
   for (int i = 0; i < NB_X; ++i)
      tab[i].valeur(i+1);
   for (int i = 0; i < NB_X; ++i)
      cout << tab[i].valeur() << endl;
}

On pourrait même y aller avec une fonction :

#include "X.h"
#include <iostream>
void afficher(const X*, int);
int main()
{
   const int NB_X = 10;
   X tab[NB_X];
   for (int i = 0; i < NB_X; i++)
      tab[i].valeur(i+1);
   Afficher(tab, NB_X);
}
void afficher(const X *tab, int n)
{
   using namespace std;
   for (int i = 0; i < n; ++i)
      cout << tab[i].valeur() << n;
}

Jusque ici, tout est propre. Évidemment, ceci étant le musée des horreurs, la sauce s'apprête à se gâter.

L'horreur : imaginons un programme plus riche qui ait deux classes, soit X et Y. Imaginons que Y soit une classe plus sophistiquée (!) que X, et munie d'un attribut supplémentaire (disons un facteur multiplicatif). La classe Y se présentera comme suit :

// Fichier Y.h
#include "X.h"
class Y
   : public X
{
   int facteur_;
public:
   Y()
      : facteur_{}
   {
   }
   int facteur() const
      { return facteur_; }
   void facteur(const int fac)
      { facteur_ = fac; }
};

... et que le programme de test ressemble maintenant à :

#include "X.h"
#include "Y.h"
#include <iostream>
void afficher(const X *, int);
int main()
{
   const int NB_Y = 10;
   Y tab[NB_Y];
   for (int i = 0; i < NB_Y; ++i)
   {
      // méthode de Y: Ok car Tableau[i] est un Y
      tab[i].facteur((i+1) * 10);
      // méthode de X: Ok car un Y est un X
      tab[i].valeur(i+1);
   }
   //
   // Compile car Tableau est un Y* et tout Y* est
   // aussi un X*
   //
   Afficher(tab, NB_Y);
}
void afficher(const X *tab, int n)
{
   using namespace std;
   for (int i = 0; i < n; ++i)
      cout << tab[i].valeur() << endl;
}

Tout compile, ce programme est correct dans afficher() quand i==0 mais devient erroné (et peut même planter!) dans afficher() alors que i vaut 1. Que se passe-t-il?

En fait, rien d'illégal qui puisse être détecté à la compilation, du moins pas par un compilateur conventionnel, mais une grave faute logique. On a profité du fait que la fonction afficher() existait déjà et avait déjà été testée et du fait qu'un Y* est aussi un X* (car la classe Y est déclarée public X).

Prudence : l'écriture tab+(i*sizeof(X)) n'équivaut pas à tab+i en terme d'arithmétique de pointeurs. Pour être stricts, il nous aurait fallu écrire reinterpret_cast<char*>(tab)+(i*sizeof(X)).

Le problème survient à l'exécution: dans la fonction afficher(), on travaille avec le tableau tab en utilisant un index entier i allant de 0 à n-1. On se souviendra que tab[i] signifie ce qui se trouve à l'adresse (tab + i fois la taille d'un élément de tab)... mais pour la fonction afficher(), tab est un pointeur de X, pas un pointeur de Y!

En effet, quand la fonction afficher() sollicite tab[i].valeur(), cela implique aller à l'endroit en mémoire situé à tab+i, donc à l'adresse tab+(i*sizeof(X)) si on considère le tout comme des bytes bruts.

void afficher(const X *tab, int n)
{
   for (int i = 0; i < n; ++i)
      cout << tab[i].valeur() << endl;
}
Pour en savoir plus (mais brièvement) sur la manière dont sont organisés les objets C++ en mémoire, voir ce site. Notez que C++ n'impose pas une structure définie aux compilateurs, mais donne des règles sémantiques de base.

Conceptuellement, écrire class Y : public X signifie (entre autre) que tout Y est aussi un X, et même plus. Et le et même plus est important ici : un Y a tous les attributs d'un X et même plus; de même, un Y a toutes les méthodes d'un X, et même plus.

Un Y devrait donc logiquement être plus gros qu'un X... et c'est le cas! Tel que l'intuition le suggère, si Y dérive de X, alors sizeof(Y)>=sizeof(X).

Ainsi, comme le montre le schéma à droite, dès que l'élément 1 du tableau tel que perçu par la procédure afficher() est atteint, la vision qu'a le programme de l'objet devient erronnée: alors que afficher() pense légitimement accéder à l'élément 1 d'un tableau de X, elle se trouve en fait quelque part au milieu de l'élément 0 d'un tableau de Y.

Toute opération faite à partir de ce point est faite d'un point de vue inexact, et devrait être considéré dangereux. Même si le programme ne plante pas, par magie ou par chance, ses résultats ne sont plus dignes de confiance.

Le bon usage : la solution la plus immédiate serait de remplacer le prototype de la procédure afficher() pour que les opérations d'indexage des tableaux y deviennent valides :

Entrée 03.0a – Procédure Afficher() munie du bon prototype dans ce cas-ci
#include "X.h"
#include "Y.h"
#include <iostream>
using namespace std;
void afficher(const Y*, int);
int main ()
{
   const int NB_Y = 10;
   Y tab[NB_Y];
   for (int i = 0; i < NB_Y; ++i)
   {
      // méthode de Y: Ok car Tableau[i] est un Y
      tab[i].facteur((i+1) * 10);
      // méthode de X: Ok car un Y est un X
      tab[i].valeur(i+1);
   }
   // Compile car Tableau est un Y* et tout Y* est
   // aussi un X*
   afficher(tab, NB_Y);
}
void afficher(const Y *tab, int n)
{
   for (int i = 0; i < n; ++i)
      cout << tab[i].valeur() << endl;
}

L'irritant de cette solution est qu'on y perd un niveau d'abstraction: on aimerait que afficher() puisse fonctionner pour toute séquence de X, qu'il s'agisse de X bruts ou de dérivés de X. Cette solution n'a de sens que pour une séquence de Y, et ne fonctionne même plus pour un tableau de X, et ce bien que seules des méthodes de X y soient utilisées.

Une autre solution est de travailler à un niveau d'abstraction plus élevé et d'utiliser un tableau de pointeurs de Y. L'avantage de ceci est que, puisque tous les pointeurs sur des données[1] sont des adresses de même taille, donc tab[i] mènera au prochain pointeur dans une séquence de pointeur, et l'appel à valeur() fonctionnera à chaque fois car tout X (incluant ses dérivés) aura sa méthode valeur() placée au même endroit (c'est ce qui faisait que l'appel à tab[0].valeur() fonctionnait dans l'horreur proposée ici).

La solution la plus élégante serait donc d'y aller comme suit :

Entrée 03.0b – Procéder par tableau de pointeurs de Y
#include "X.h"
#include "Y.h"
#include <iostream>
using pX = X*; // pour simplifier l'écriture
using pY = Y*; // pour simplifier l'écriture
void afficher(const pX *, int);
int main()
{
   const int NB_Y = 10;
   pY tab[NB_Y];
   // Créer les Y manuellement
   for (int i = 0; i < NB_Y; ++i)
      tab[i] = new Y;
   for (int i = 0; i < NB_Y; ++i)
   {
      tab[i]->facteur((i+1) * 10);
      tab[i]->valeur(i+1);
   }
   afficher(tab, NB_Y);
   // Détruire les Y manuellement
   for (int i = 0; i < NB_Y; ++i)
      delete tab[i];
}
//
// La beauté ici est que afficher() ne sait du tableau
// que peu de choses, sinon que chaque élément qui y
// est pointé est au moins un X
//
void afficher(const pX *tab, int n)
{
   for (int i = 0; i < n; i++)
      cout << tab[i]->valeur() << endl;
}

Entrée 04 – Confondre pointeur passé par valeur et pointeur passé par adresse

Ce qui suit repose sur des techniques et des trucs et un peu plus avancés que celles et ceux proposés plus haut, mais qui sont très utiles dans l'application de divers schémas de conception et dans mes cours de systèmes client/ serveur.

L'horreur exposée ici est peu dommageable mais dénote deux erreurs de compréhension de la mécanique de C++. L'une de ces deux erreurs est petite alors que l'autre porte à plus de conséquences.

La situation : imaginons une classe asbtraite IMachin (une interface) exposant entre autres des méthodes de classe (qualifiées static en C++) pour fabriquer et pour libérer un IMachin. IMachin étant abstraite (donc nécessairement vouée à un usage polymorphique), elle exposera un destructeur virtuel assurant, à l'application sur un IMachin* de l'opérateur delete, l'appel éventuel du bon destructeur pour l'objet réellement pointé.

Le programme ci-dessous, séparé en trois fichiers, donne un exemple simplifié de cetet stratégie. Remarquez que le destructeur de IMachin est protégé pour forcer le code client d'un IMachin* à passer par IMachin::liberer() pour se débarrasser d'un tel objet. Ceci donne le plein contrôle à IMachin sur la gestion de ses enfants et lui permet (entre autres) de choisir entre créer un IMachin pour chaque client ou partager un IMachin entre plusieurs clients.

#ifndef IMACHIN_H
#define IMACHIN_H
//
// IMachin.h
//
struct IMachin
{
   virtual void f() = 0; // un service très important de IMachin
protected:
   virtual ~IMachin() = default;
public:
   static IMachin *creer(); // crée un dérivé de IMachin (petite fabrique simpliste)
   static void liberer(IMachin *); // libère un IMachin
};
#endif
//
// IMachin.cpp
//
#include "IMachin.h"
class MonMachin
   : public IMachin
{
public:
   void f() { /* .. du code très important! */ }
}
IMachin* IMachin::creer()
   { return new MonMachin; /* simpliste */ }
void IMachin::liberer(IMachin *p)
{
   // code pour libérer l'objet pointé par p
}
//
// Test.cpp
//
#include "IMachin.h"
int main()
{
   // créer un dérivé de IMachin
   IMachin *p = IMachin::creer();
   // utiliser cet objet
   p->f();
   // le libérer
   IMachin::liberer(p);
}

La question à laquelle nous allons nous attaquer est l'implémentation de IMachin::liberer().

L'horreur : soit l'implémentation de IMachin::liberer() suivante :

Horreur 04.0 (premier jet)
//...
void IMachin::liberer(IMachin *p)
{
   if (p)
   {
      delete p;
      p = nullptr;
   }
}
// ...

La première horreur est simple et est plus une constatation qu'une horreur. En C++, delete nullptr est sans conséquences (pour plusieurs raisons techniques), ce qui implique que ce code est inutilement lent. Le test if(p) (qui équivaut à if(p!=0) ou à if (p!=nullptr) our celles et ceux qui préfèrent cette notation) n'est d'aucune utilité et peut tout simplement être éliminé.

Nous obtenons alors cette version de IMachin::liberer() :

Horreur 04.0 (deuxième jet)
//...
void IMachin::liberer(IMachin *p)
{
   delete p;
   p = nullptr;
}
// ...

Cette version présente une horreur conceptuelle plus profonde et qui, sans être dommageable ici, constitue un signal que la personne ayant rédigé le code n'a pas saisi une particularité fondamentale quant aux pointeurs et aux paramètres par valeur.

Ici, p est l'adresse de quelque chose qui est au moins un IMachin. Cependant, p est une variable locale à la méthode liberer(). Si nous examinons le programme de test plus haut, la variable p, bien qu'il s'agisse d'un pointeur, est passée par valeur à la méthode IMachin::liberer().

Comprenons-nous : la variable p de main() et la variable p de IMachin::liberer() sont, dans ce test, deux variables différentes qui contiennent la même valeur. Il se trouve simplement que cette valeur est l'adresse d'un objet.

Conséquence: appeler delete sur l'une ou l'autre détruira le même objet. En retour, affecter nullptr à la variable p de IMachin::liberer() n'a aucune conséquence sur la variable p de main() puisqu'il s'agit de deux variables différentes.

Le bon usage : faire en sorte que IMachin::liberer() fasse son travail et rien d'autre, tout simplement :

Entrée 04.0a
//
// IMachin.cpp
//
#include "IMachin.h"

class MonMachin
   : public IMachin
{
public:
   void f()
      { /* .. du code très important! */ }
}
IMachin* IMachin::creer()
   { return new MonMachin; /* simpliste */ }
void IMachin::liberer(IMachin *p)
   { delete p; }

//
// Test.cpp
//
#include "IMachin.h"
int main()
{
   // créer un dérivé de IMachin
   auto p = IMachin::creer();
   // utiliser cet objet
   p->f();
   // le libérer
   IMachin::liberer(p);
}

C'est bien plus simple, non? Si vous souhaitez vraiment réinitialiser le pointeur passé en paramètre à IMachin::Liberer(), deux options s'offrent à vous.

La première est de modifier la signature de IMachin::Liberer() pour qu'elle reçoive en paramètre l'adresse d'un pointeur de IMachin. Cela nous donnerait :

Entrée 04.0b
#ifndef IMACHIN_H
#define IMACHIN_H
//
// IMachin.h
//
struct IMachin
{
   virtual void f() = 0; // un service très important de IMachin
protected:
   virtual ~IMachin() = default;
public:
   static IMachin *creer(); // crée un dérivé de IMachin (petite fabrique simpliste)
   static void liberer(IMachin **); // libère un IMachin
};

#endif
//
// IMachin.cpp
//
#include "IMachin.h"
class MonMachin
   : public IMachin
{
public:
   void f() { /* .. du code très important! */ }
}
IMachin* IMachin::creer()
   { return new MonMachin; }
void IMachin::liberer(IMachin **p)
{
   delete *p;
   *p = nullptr;
}
//
// Test.cpp
//
#include "IMachin.h"
int main()
{
   // créer un dérivé de IMachin
   auto p = IMachin::creer();
   // utiliser cet objet
   p->f();
   // le libérer
   IMachin::liberer(&p);
}

Le défaut de cette stratégie est qu'elle dénature le code client. Une autre alternative, un peu plus propre celle-là, est de passer le pointeur par référence :

Entrée 04.0c
#ifndef IMACHIN_H
#define IMACHIN_H
//
// IMachin.h
//
using ptrMachin = IMachin*; // alias pour alléger la syntaxe
struct IMachin
{
   virtual void f() = 0; // un service très important de IMachin
protected:
   virtual ~IMachin() = default;
public:
   static ptrMachin creer(); // crée un dérivé de IMachin (petite fabrique simpliste)
   static void liberer(ptrMachin &); // libère un IMachin
};
#endif
//
// IMachin.cpp
//
#include "IMachin.h"
class MonMachin
   : public IMachin
{
public:
   void f() { /* .. du code très important! */ }
}
ptrMachin IMachin::creer()
   { return new MonMachin; }
void IMachin::liberer(ptrMachin &p)
{
   delete p;
   p = nullptr;
}
//
// Test.cpp
//
#include "IMachin.h"
int main()
{
   // créer un dérivé de IMachin
   auto p = IMachin::creer();
   // utiliser cet objet
   p->f();
   // le libérer
   IMachin::liberer(p);
}

En pratique, cependant, automatiser l'affectation de nullptr à un pointeur nouvellement détruit est une mauvaise idée. Si un pointeur est destiné à ne plus être utilisé une fois détruit, cela rend superflue une affectation automatisée de nullptr. Dans la majorité des cas, un pointeur nouvellement détruit ne sera pas réutilisé ou sera simplement réutilisé à brève échéance, deux cas pour lesquels une affectation supplémentaire est du simple gaspillage. Il est préférable d'affecter nullptr aux pointeurs nouvellement détruits seulement dans les cas où cela s'avère pertinent.

Si vous faites du code de ce genre, il est probable que vous ayez avantage à investiguer l'idiome pImpl...


[1] Notez que C++ ne garantit pas que les adresses des fonctions et les adresses des données (incluant les objets) soient de même taille: il existe des machines pour lesquelles le schème du code et le schème des données sont voués à des règles différentes l'un de l'autre.


Valid XHTML 1.0 Transitional

CSS Valide !