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.

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) 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
      // ...
   }
}

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;

Une fois un pointeur déclaré, on l'utilise tel quel si on souhaite le traiter comme un pointeur. 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

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

Lors d'une déclaration de variable, placer & entre le type et le nom déclare un 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;

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!)

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 typedef 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

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*!)

Un pointeur permet d'allouer de la mémoire dynamiquement pour 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;
  • ''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égattif, 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>
using namespace std;
struct X {
   char c;
};
struct Y {
   char c[4];
};
X f();
Y g();
int main() {
   if (sizeof(f()) < sizeof(g())) {
      cout << 'A' << endl;
   } else {
      cout << 'B' << endl;
   }
}

Qu'affichera le programme à droite? Expliquez pourquoi.

#include <iostream>
using namespace std;
struct X {
   char c[4];
};
int glob = 3; // beurk
X f() {
   ++glob;
   return X{};
}
int main() {
   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;
}

Valid XHTML 1.0 Transitional

CSS Valide !