Cette fiche introduit la notion d'arithmétique de pointeurs (opérations sur des adresses) et donne quelques compléments sur les types pointeur.

1. Opération sur les adresses : arithmétique de pointeurs

On peut écrire deux types d'opératiosn avec des pointeurs :

  • des opérations arithmétiques
  • et des expressions booléennes

Commençons par l'arithmétique. L'arithmétique de pointeur, en C, c'est la possibilité d'effectuer des calculs sur des adresses mémoire - donc sur des pointeurs.

La première opération possible est : pointeur + unentier (ou pointeur - unentier)

Le résultat de pointeur + unentier est une nouvelle adresse, décallée de unentier cases.

Attention : La taille d'une "case" (en nombre d'octets) est déterminée par le type du pointeur. L'arithmétique de pointeurs dépend du type du pointeur et plus précisément de la taille mémoire occupée par la chose pointée. Les calculs sont entendus en nombre de cases, et non pas en nombre d'octets.

Ainsi :

  • Si pointeur est de type int *, alors pointeur + N décalle la case mémoire de N cases, et ajoutera donc (N*4) octets à la valeur de pointeur, puisqu'un int occupe 4 octets en mémoire;
  • Si pointeur est de type char *, alors pointeur + N décalle la case mémoire de N cases, et ajoutera donc (N*1) octets à la valeur de pointeur, puisqu'un code char occupe 1 octet;
  • Si pointeur est de type double *, alors pointeur + N décalle la case mémoire de N cases, et ajoutera donc (N*8) octets à la valeur de pointeur, puisqu'un double occupe 8 octets;
  • etc.

La seconde opération possible est : pointeur2 - pointeur1.

Le résultat de pointeur2 - pointeur1 est un entier : le nombre de cases (pas d'octets !) séparant les deux adresses.

Attention : les deux pointeurs doivent être de même type (exemple : des pointeurs sur deux entiers)

On reprend notre histoire de pochettes, mais cette fois ci on suppose que les pochettes sont groupées par paquet de 10 : chaque paquet de 10 pochette représente un chapitre. Le chapitre 1 débute à la pochette numéro 0, le chapitre débute à la pochette numéro 10, etc.

Désigner le chapitre (5+1), c'est désigner le chapitre 6, c'est à dire la pochette numéro (d'adresse) 60.

Si une pochette (un pointeur) contient l'adresse d'un chapitre, par exemple le numéro 50 (adresse du chapitre 5), alors ajouter 1 à la valeur de cette pochette de type "adresse d'un chapitre" c'est désigner le chapitre suivant , c'est à dire la pochette numéro 60.

Si une pochette (un pointeur) contient l'adresse d'une pochette, par exemple à nouveau 50, alors ajouter 1 à la valeur de cette pochette de type "adresse d'une pochette" c'est désigner la pochette suivante , c'est à dire la pochette numéro 51.

Et en ce qui concerne les opérations booléennes sur les pointeurs :

  • ptr1 == ptr2 : est vrai si les deux pointeurs ont la même valeur (si ils pointent la même adresse)
  • ptr1 != ptr2 : est vrai si les deux pointeurs ont des valeurs différentes (si ils pointent deux adresses différentes)
  • ptr1 > ptr2 : est vrai si la valeur de ptr1 est plus grande que celle de ptr2 (si ptr1 pointe plus loin en mémoire que ptr2)
  • Et ainsi de suite avec les opérateurs <, <=, >=

Attention : les deux pointeurs comparés doivent être de même type (exemple : adresses de deux entiers) !

Voyons tout cela sur un exemple :


int main() {
   int i = 8;
   int * ptr = &i ;  // ptr pointe i
   printf("Valeur de ptr (adresse de i) : %p\n", ptr);
   printf("Valeur de ptr+1 (adresse suivant la variable i) : %p\n\n",  ptr+1  );


   // RAPPEL: pour un tableau de N éléments:
   // La 1ère case du tableau est à l'indice entier 0
   // La dernière case du tableau est à l'indice entier (N-1)
   // RAPPEL : un double occupe 8 octets en mémoire
   double tab[10];

   if(tab == & tab[0]) {
     printf("La valeur de la variable tableau tab est bien la même que l'adresse de la première case\n");
   }
   printf("Adresse de la première case du tableau : %p\n", tab);

   double * pa = & tab[0];
   printf("Valeur du pointeur qui pointe la 1ère case du tableau : %p\n\n", pa);

   double * pb = (pa + 1);   // ARITHMETIQUE DE POINTEUR : adresse + entier => adresse
   // Comme pa est de type (double *), et qu'un double occupe 8 octets,
   //  alors ajouter pa+1 (cases) ajoute en fait (1*8) octets à l'adresse pa.
   // Autrement dit : pb pointe la case 2ème case (d'indice 1) du tableau !

   printf("Valeur du pointeur pb : %p\n\n", pb);
   printf("Vérifions que c'est bien l'adresse de la 2ème case du tableau : %p\n\n", & tab[1]);

   if(pb > pa) {
     printf("La case mémoire pointée par pb est bien plus loin que celle pointée par pa\n\n");
   }

   // vérifions encore...
   printf("Valeur de pa+7, adresse de la 8ème case du tableau : %p\n", pa+7);
   printf("(On vérifie que (pa+7) ajoute bien 7*8 == 56 octets à l'adresse pa.)\n\n");


   double *pc = & tab[5];
   printf("Valeur de pc, c'est à dire adresse de la 6ème case du tableau : %p\n", pc);

   int nbCases = pc-pa; // // ARITHMETIQUE DE POINTEUR : adresse - adresse => entier
   // Les deux adresse p5 et p0 sont toutes deux de type (double *) => ca marche
   // La soustraction des deux adresses de même type est comptée en nombre de cases
   //   (ici : nombre de paquet de 8 octets, puisque les pointeurs sont de type double *)

   printf("Nombre de cases séparant les adresses pointées par pc et par pa : %d\n", nbCases);
}
       

Lorsqu'on exécute ce programme, en supposant que la variable i est placée à l'adresse 0x7fff58330a8c et le tableau tab à l'adresse 0x7fff4ff8eaa0, alors on obtient dans le Terminal :


Valeur de ptr (adresse de i) : 0x7fff58330a8c
Valeur de ptr+1 (adresse suivant la variable i) : 0x7fff58330a90

La valeur de la variable tableau tab est bien la même que l'adresse de la première case
Adresse de la première case du tableau : 0x7fff4ff8eaa0
Valeur du pointeur qui pointe la 1ère case du tableau : 0x7fff4ff8eaa0

Valeur du pointeur pb : 0x7fff4ff8eaa8
Vérifions que c'est bien l'adresse de la 2ème case du tableau : 0x7fff4ff8eaa8

La case mémoire pointée par pb est bien plus loin que celle pointée par pa

Valeur de pa+7, adresse de la 8ème case du tableau : 0x7fff4ff8ead8
(On vérifie que (pa+7) ajoute bien 7*8 == 56 octets (ou 0x38 en hexa) à l'adresse pa).

Valeur de pc, c'est à dire adresse de la 6ème case du tableau : 0x7fff58330ac8
Nombre de cases séparant les adresses pointées par pc et par pa : 5
       

On observe bien, dans cet exemple, que l'arithmétique de pointeur travaille en "nombre de cases" :

  • 4 octets si les adresses manipulées sont de type int *
  • 8 octets si les adresses manipulées sont de type double *
  • 1 octet si les adresses manipulées sont de type char *
  • etc.

On retrouvera cette propriété, bien utile, dans la fiche suivante "pointeurs et tableaux".

2. Quelques compléments sur les types des pointeurs

Puisque les pointeurs sont typés (par le type de la chose pointée), il convient de prendre garde à ne pas mélanger les types des pointeurs.

On évitera, par exemple, d'affecter à un pointeur - sur - float l'adresse d'une variable entière, ou encore d'affecter une valeur entière à un pointeur (un entier n'est pas une adresse !).

Si on écrit le code :


int main() {
   int i = 8;
   float * ptr = &i;  // oups !
   int * p = 78;      // oups !
   double * p_y;
   p_y = ptr ;        // oups !
   if(p_y == p) {
     printf("p_y et p pointent la même adresse\n");
   }
}
         
alors, aux lignes 3, 4 et 6, le compilateur va vous exprimer son grand mécontentement, avec des messages de "warning" tels que :

ttt.c:3:13: warning: incompatible pointer types initializing 'float *' with an expression of type 'int *' [-Wincompatible-pointer-types]
      float * ptr = &i;  // oups !
                  ^     ~~
ttt.c:4:11: warning: incompatible integer to pointer conversion initializing 'int *' with an expression of type 'int' [-Wint-conversion]
      int * p = 78;      // oups !
              ^
ttt.c:6:9: warning: incompatible pointer types assigning to 'double *' from 'float *' [-Wincompatible-pointer-types]
      p_y = ptr ;        // oups !
          ^
ttt.c:7:12: warning: comparison of distinct pointer types ('double *' and 'int *') [-Wcompare-distinct-pointer-types]
      if(p_y == p) {
             ^
        

Ces warnings du compilateur "incompatible pointer type" sont très importants. Il ne faut pas les ignorer. Pensez aux équations aux dimensions en Physique. En physique, dans une équation, la dimension de ce qui est à droite et à gauche doivent être les mêmes, sans quoi c'est que vous faites n'importe quoi ! Eh bien, c'est la même chose lorsqu'on programme en C avec des pointeurs.

Mais pourquoi donc le compilateur génère-t-il des Warnings, et non pas des erreurs ?

Eh bien parce que, en C, un développeur averti a le droit de "tricher" sur les types des choses pointées !

Cela permet par exemple de manipuler l'espace mémoire d'une variable int non pas comme un ensemble cohérent de 4 octets, mais comme 4 octets séparés, comme dans l'exemple suivant :


int main() {
  int i = 8;  // i est un int, donc occupe 4 octets en mémoire

  // Trichons un peu, et faisons comme si, à l'adresse de i, nous avions
  // non pas une variable de type int, mais un simple octet...
  char * ptr = &i;// ptr pointe i...
                  // Mais en considérant cette adresse comme étant celle d'un octet
                  // (un char) et non pas d'un entier !
  *ptr = 0x00;    // On affecte 0 au 1er octet de la variable i
  ptr ++;         // ou ptr = ptr+1 (arithmétique de pointeur)
                  // Désormais, ptr pointe le 2nd octet de la variable i,
                  // considéré comme un char
  *ptr = 0x00;    // On affecte 0 au 2eme octet de la variable i
  ptr ++;         // ptr pointe le 3eme octet de la variable i, considéré comme char
  *ptr = 0xFF;    // On affecte 255 au 3eme octet de la variable i
  ptr ++;         // ptr pointe le 3eme octet de la variable i, considéré comme char
  *ptr = 0x01;    // On affecte 1 au 4eme octet de la variable i

  // autrement dit, on a manipulé i comme une suite de 4 octets
  // pour "allumer" un à un les bits de chacun de ces octets !
  printf("i ? %d\n", i); // affiche la nouvelle valeur de i
 }
          

Ce code est tout à fait licite en C !

Il est un exemple de la puissance des pointeurs pour manipuler la mémoire.

Certains d'entre vous auront la joie de tâter de cela en 2ème année. Mais pour cette année, on restera sage et on évitera de mélanger les types de pointeurs...

Vous savez qu'en C, le mot clé void ("vide", "néant") est un type qui permet, par exemple, de préciser qu'une fonction ne retourne aucune valeur (c'est ce qu'on appelle une procédure).

void est aussi utile pour déclarer une variable pointeur, qui stockera donc une adresse, lorsqu'on ne veut pas préciser le type de la chose pointée. Un pointeur "pointant n'importe quoi" en mémoire, en quelque sorte. C'est ce qu'on appelle un "pointeur générique". On peut écrire par exemple :


int main() {
  int i = 8;

  void * p = &i;
  // p est un pointeur de type "void".
  // Sa valeur est l'adresse de la variable i.
  // Ce code ne provoque pas de warning de compilation :
  // on peut affecter un pointeur - sur - void l'adresse de n'importe quel type de variable.
  // ... mais, par contre, le pointeur p ne "sait pas" qu'il pointe un entier
 }
          

Attention toutefois : puisque un pointeur void ne sait pas ce qu'il pointe, il n'est pas possible de le déréférencer, ni de l'utiliser pour faire de l'algorithmique de pointeurs. Ainsi, le code suivant ne compile pas :


int main() {
  int i = 8;

  void * p = &i;
  // p est un pointeur de type "void".
  // Sa valeur est l'adresse de la variable i
  // ... mais p ne sait pas qu'il pointe un entier

  *p = 10298 ;        // ERREUR de compilation.
                      // Car comment savoir sur combien d'octets affecter
                      // la valeur 10298 ?
  void * p2 = p + 9 ; // ERREUR de compilation (... en général ...
                      // mais certains compilateurs sont peut regardant ...)
                      // Car combien d'octets faudrait-il ajouter à l'adresse p ?
 }
          

Mais alors, à quoi sert un pointeur de type void * ?

Eh bien, à de nombreuses choses !

Pour ne donner qu'un exemple, c'est parfois très utile, comme paramètre de fonctions, lorsque la fonction doit travailler sur des adresses mémoire, sans qu'on souhaite préciser le type pointé.

Ainsi, voici le prototype et le début de la documentation de la fonction memcpy de la librairie standard C (voir man memcpy pour plus de détail) :


void * memcpy(void *restrict dst, const void *restrict src, size_t n);
DESCRIPTION
   The memcpy() function copies n bytes from memory area src to memory area dst.
   (etc.)
          

S'entrainer

Comptons sur nos doigts ! On considère le code suivant :

      #include <stdio.h>

      int main() {
          char c = 'a';
          char * p1 = &c;

          int i = 12;
          int * p2 = &i;

          double tab[4] ;
          double * p3 = tab;

          printf("valeur de l'adresse stockée dans p1 %p\n", p1);
          printf("valeur de l'adresse stockée dans p2 %p\n", p2);
          printf("valeur de l'adresse stockée dans p3 %p\n", p3);

          p1 = p1 + 1 ;
          p2 = p2 + 2 ;
          p3 = p3 + 3 ;

          printf("Nouvelle valeur de l'adresse stockée dans p1 %p\n", p1);
          printf("Nouvelle valeur de l'adresse stockée dans p2 %p\n", p2);
          printf("Nouvelle valeur de l'adresse stockée dans p3 %p\n", p2);

          *p1 = 'b';
          *p2 = 89;
          *p3 = 2.71828;

          printf("tab[3] = %lf\n", tab[3]);

          return 0;
      }
    

On suppose que les lignes 13 à 15 de ce code affichent dans le Terminal :


valeur de l'adresse stockée dans p1 0x7fff5465fa9b
valeur de l'adresse stockée dans p2 0x7fff5465fa8c
valeur de l'adresse stockée dans p3 0x7fff5465faa0
    

Quelles sont les réponses correctes ?

Les lignes 21 à 23 affichent dans le Terminal :

        Nouvelle valeur de l'adresse stockée dans p1 0x7fff5465fa9c
        Nouvelle valeur de l'adresse stockée dans p2 0x7fff5465fa94
        Nouvelle valeur de l'adresse stockée dans p3 0x7fff5465fa94
Les lignes 21 à 23 affichent dans le Terminal :

        Nouvelle valeur de l'adresse stockée dans p1 0x7fff5465fa9c
        Nouvelle valeur de l'adresse stockée dans p2 0x7fff5465fa8e
        Nouvelle valeur de l'adresse stockée dans p3 0x7fff5465faa3
    
A lignes 25 et 26, euh... ce n'est pas bien correct de déréférencer ces pointeurs p1 et p2 pour changer les valeurs des cases mémoires pointées ! A lignes 27, euh... ce n'est pas bien correct de déréférencer ce pointeur p3 pour changer la valeur de la case mémoire pointée ! La ligne 29 affiche tab[3] = 2.718280

L'arithmétique de pointeur dépend du type de pointeur, et de la taille occupée en mémoire par la chose pointée.

Or : un char occupe 1 octet, un int occupe 4 octets (sur les machines de l'école... ça peut être plus ou moins sur d'autres processeurs), un double occupe 8 octets

Pour les 2 premières questions, il suffit alors de savoir compter en hexadécimal !

Pour les 3 dernières questions, il faut vous demander quelle est la variable pointée par les pointeurs après les opérations arithméthiques. Est-ce bien une case valide contenant une variable du bon type ?