Bref sur les pointeurs

Ce qui suit se veut un bref survol des pointeurs, destiné à celles et ceux parmi vous qui sont pas encore habiles pour les comprendre et les manipuler.

Les symboles et concepts clés à comprendre sont les suivants.

Bref sur sizeof

Note : pour en savoir plus sur sizeof, voir ../Divers--cplusplus/sizeof.html

En C et en C++ (et en C#, d'ailleurs, même si on ne le voit pas à moins d'écrire du code de bas niveau), le type utilisé pour les grosseurs est std::size_t.

Le type std::size_t est un alias (un typedef ou, en termes plus contemporains, un using) pour un type entier non-signé qui dépend de la plateforme (ça peut être unsigned int, unsigned long, unsigned long long, peu importe). On ne veut pas savoir lequel.

L'opérateur sizeof(expr), qui peut aussi s'exprimer sizeof x si x est un objet, retourne dès la compilation la taille en bytes de expr; cet opérateur retourne donc un size_t. Ainsi, à droite, l'affichage de sizeof(int) nous montera combien de bytes occupe un int dans notre programme, alors que l'affichage de sizeof d nous indiquera combien de bytes occupe un double dans notre programme puisque d est un double.

Note amusante : l'expression sizeof(f()) est équivalente à sizeof(X) car un appel à f() retourne un X, mais sizeof(f()) n'appelle pas f() (l'opérateur sizeof() est résolu à la compilation!).

X f();
int main() {
   cout << sizeof(int) << endl;
   double d = 10.5;
   cout << sizeof d << endl
        << sizeof(f()) << endl;
   size_t n0 = sizeof(X),
          n1 = sizeof(float);
   if (n0 < n1) { // si un X est plus petit qu'un float
      // ...
   }
}

Déclarer un pointeur

Lors d'une déclaration de variable, placer * entre le type et le nom déclare un pointeur.

Ainsi, à droite :

  • p0 est un pointeur de float et n'est pas initialisé (on ne sait pas où il pointe)
  • p1 est un pointeur nul de double, donc qui mène à une adresse que nous n'utilisons pas (par convention). Le pointeur nul, nullptr, est considéré comme le « zéro » des types pointeurs; conséquemment, on aurait aussi pu écrire double *p1 = {}; ou simplement double *p1{}; pour en arriver au même résultat, et
  • p2 est un pointeur abstrait qui mène au même  endroit que p1
float *p0;
double *p1 = nullptr;
void *p2 = p1;

Utiliser un pointeur

Une fois un pointeur déclaré, il peut être utilisé en tant que pointeur (donc en tant qu'adresse typée) comme en tant qu'outil d'accès indirect vers un pointé.

Quand on souhaite utiliser ce vers quoi il pointe, il faut le déréférencer (le précéder d'un *).

Tous les pointeurs sont des adresses, donc tous les pointeurs occupent la même quantité d'espace en mémoire. À titre d'exemple, sizeof(string*)==sizeof(char*) même s'il est clair que sizeof(string)>sizeof(char).

char *p = ...;
char *q = ...;
if (p == q) { // si p et q pointent au même endroit
   // ...
}
// ...
*p = 'z'; // écrire 'z' là où mène p

Lorsque le pointé est un objet complexe, il est aussi possible d'accéder à ses membres à travers l'opérateur -> comme le montre l'exemple à droite.

#include <string>
  #include <cassert>
  std::string::size_type longueur(const std::string *s) {
     assert(s); // s doit être non-nul
     return s->size(); // ou return (*s).size(); au choix
  }

Prendre l'adresse d'un objet

Pour faire pointer un pointeur sur un objet, il est souvent utile de pouvoir prendre l'adresse de cet objet.

Une fois une variable déclarée, la précéder d'un & signifie prendre son adresse. Ainsi, à droite :

  • s est un short, et
  • p pointe sur s.
short s = 7;
cout << s << endl; // affiche 7
cout << &s << endl; // affiche l'adresse de s
short * p = &s; // p pointe sur s
cout << *p << endl; // affiche 7

Il est possible de prendre l'adresse d'un pointeur, même si on le fait rarement en pratique.

Dans le code à droite, p pointe sur i alors que pp pointe sur p (pp est un int**, donc un pointeur sur un pointeur de int). On utilise souvent des alias pour alléger la syntaxe quand on a besoin de jouer dans ces eaux-là.

int i = 3;
int *p = &i;
int **pp = &p;
using pointeur_de_int = int*;
pointeur_de_int p2 = &i; // p2 pointe sur i
pointeur_de_int *pp2 = &p; // pp2 pointe sur p

Distinguer « prendre l'adresse » et « déclarer une référence »

Note : pour en savoir plus sur les références, voir ../Divers--cplusplus/References.html

Il est facile de confondre « prendre l'adresse » et « déclarer une référence », les deux écritures étant semblables et reposant sur le symbole &. La différence entre les deux se situe au contexte d'utilisation : est-ce que & apparaît dans une déclaration ou est-ce que & est utilisé sur un objet existant?

Comme vu plus haut, appliquer & sur un objet existant signifie prendre son adresse.

Lors d'une déclaration de variable, placer & entre le type et le nom déclare une référence. Ainsi, à droite :

  • d est un double
  • r0 est illégal (une référence doit être initialisée dès la déclaration), et
  • r1 est une référence sur d.
double d = 0.5;
double &r0; // illégal, car non-initialisée
double & r1 = d;

Distinguer pointeurs et références

Examinons maintenant comment il est possible de bien utiliser les pointeurs et, en comparaison, de bien utiliser les références.

Une référence est une sorte d'alias pour ce à quoi elle réfère. Une fois associée à quelque chose, elle y est liée pour toute son existence.

Un pointeur peut se déplacer en mémoire. C'est ce qui le rend souple... et dangereux.

Dans le code à droite, la dernière instruction écrit la valeur 3 à ce qui serait la position 6 de vals, or les positions légales dans vals vont de 0 à 4 inclusivement. Ce programme est brisé.

int vals[] { 2, 3, 5, 7, 11 };
int & r = vals[1];
r = -r; // vals[1] == -3 maintenant
++r; // vals[1] == -2 maintenant
int *p = &vals[1]; // ou encore int *p = vals + 1 (même chose)
*p = -8; // vals[1] vaut maintenant -8
cout << r << endl; // affiche -8
++p; // p pointe maintenant sur vals[2]
cout << p << endl; // affiche l'adresse vers laquelle pointe p
cout << (vals + 2) << endl; // idem
cout << &vals[2] << endl; // idem
*p += 4; // vals[2] vaut maintenant 9
p += 4; // oups, p pointe hors du tableau vals
*p = 3; // le programme est maintenant incorrect (boum!)

Comm vue plus haut, quand un pointeur mène sur un objet, il est possible d'accéder aux membres de cet objet par l'opérateur -> ou en déréférençant le pointeur puis en utilisant l'opérateur . comme pour les objets accédés directement.

Évidemment, avec une référence, la syntaxe est la même qu'avec un référé.

struct X {
   int f() const {
      return 3;
   }
};
X x;
cout << x.f() << endl;
X *p = &x;
cout << (*p).f() << endl; // Ok
cout << p->f() << endl; // Ok
cout << p.f() << endl; // illégal (p est un pointeur de X, pas un X!)
X &r = x; // r réfère à x
cout << r.f() << endl; // Ok
cout << r->f() << endl; // illégal (p est une référence sur un X, pas un X*!)

Détails divers

Un pointeur permet de gérer de la mémoire allouée dynamiquement, pour faire en sorte que le pointé survive à la portée du pointeur.

En C++ contemporain, il est rare qu'on manipule de la mémoire de cette manière. On privilégie les conteneurs tels que vector ou string, ou les pointeurs intelligents comme unique_ptr.

X *p = new X;
// ...
delete p;

Un void* est un pointeur abstrait. Tout pointeur peut être implicitement converti en void* (tout pointeur est une adresse). Par contre, pour utiliser un void* autrement que comme une vulgaire adresse, il faut le transtyper (faire un Cast).

string msg = "J'aime mon prof";
string *p0 = &s; // Ok
void *p1 = p0; // Ok
string *p2 = p1; // illégal
string *p3 = static_cast<string*>(p1); // mensonge correct
int *p4 = static_cast<int*>(p1); // mensonge erroné: ça passe, mais danger!!!

L'arithmétique sur les pointeurs stresse des gens, mais c'est assez simple :

  • Si p est un T*, alors ++p avance p d'un T en mémoire (donc de sizeof(T) bytes en mémoire)
  • Ainsi, à droite, chaque fois qu'on fait ++p, on avance au prochain élément du tableau vals
  • L'expression p += n; signifie avancer p de n fois la taille de ce vers quoi il pointe
  • L'expression p + n retourne l'adresse du n-ième élément après p
  • L'expression p[n] est équivalente à l'expression *(p+n) si p est un pointeur et n est une sorte d'entier

Quand on veut avancer d'un byte à la fois, un utilise des char* car sizeof(char)==1.

On ne peut pas faire d'arithmétique sur des void* car sizeof(void) est illégal.

int vals[] { 2, 3, 5, 7, 11 };
for (int *p = &vals[0]; p != &vals[5]; ++p)
   *p = (*p) * (*p); // ce serait plus beau avec une fonction carre(int) :)

Petit quiz

Dans le code à droite, expliquez :

  • Dans quel cas valA sera true
  • Dans quel cas valB sera true, et
  • Dans quel cas valC sera true
char *f();
char *g();
// ...
char *p = f(), *q = g();
bool valA = *p == *q;
bool valB = p == q;
bool valC = &p == &q;

Réécrivez le code à droite pour qu'il fasse la même chose sans avoir recours à un indice. Si votre fonction ne trouve pas d'élément négatif, elle devrait retourner un pointeur sur « l'élément » qui serait juste après le dernier élément du tableau.

size_t trouver_negatif(const float tab[], size_t nelems) {
   for (size_t i = 0; i < nelems; ++i)
      if (tab[i] < 0)
         return i;
   return nelems;
}

Qu'affichera le programme à droite? Expliquez pourquoi.

#include <iostream>
struct X {
   char c;
};
struct Y {
   char c[4];
};
X f();
Y g();
int main() {
   using namespace std;
   if (sizeof(f()) < sizeof(g())) {
      cout << 'A' << endl;
   } else {
      cout << 'B' << endl;
   }
}

Qu'affichera le programme à droite? Expliquez pourquoi.

#include <iostream>
struct X {
   char c[4];
};
int glob = 3; // beurk
X f() {
   ++glob;
   return {};
}
int main() {
   using namespace std;
   if (sizeof(f()) > sizeof(char)) {
      cout << 'A' << endl;
   }
   cout << glob << endl;
}

Dans le programme à droite, faitres en sorte que main() appelle f() en lui passant l'adresse de i.

void f(int*);
int main() {
   int i = 3;
   // vode code va ici
}

Dans le programme à droite, écrivez une fonction vlimeuse() qui fera en sorte que, suite à l'appel dans dans main(), p pointera sur glob.

#include <iostream>
using namespace std;
int glob = 3;
// vode code va ici
int main() {
   int *p {};
   vlimeuse(&p);
   *p = 4;
   cout << glob << endl;
}

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !