Des pointeurs et des hommes (4)

Previous Next

Hello ! Décidément - vacances obligent - les mises à jour se font sur ce blog à une fréquence bien plus élevée que je ne le pensais ! Aujourd’hui, je vous propose de continuer notre découverte des pointeurs en allant creuser un peu du côté des tableaux et de leur lien avec les pointeurs.

Tableaux et pointeurs

En C, la forme la plus simple de tableau est le tableau statique. Pour le déclarer, on procède de la façon suivante :

int jolitab[10] ;

Avec cette déclaration, le compilateur réserve de la place en RAM, suffisamment pour héberger 10 entiers.

Comme vous l’a surement expliqué votre prof de C, les cases de notre tableau sont numérotées à partir de zéro. L’accès à chaque case se fait avec une syntaxe simple dans laquelle on utilise le “numéro de case” entre crochets.

Par exemple, pour mettre les valeurs 0,1,2…9 dans notre tableau, on pourra écrire :

int
main()
{
    int jolitab[10] ;
    int i ;

    for (i=0; i<10; i++)
        jolitab[i] = i ;

    return 0 ;
}

En RAM, cela ressemble à ceci :

image

Chaque case du tableau est un entier tout à fait habituel, codé sur 4 octets, et les cases sont stockées les unes derrière les autres.

On peut d’ailleurs le vérifier très facilement en modifiant un tout petit peu notre programme :

#include <stdio.h>

int
main()
{
    int jolitab[10] ;
    int i ;

    for (i=0; i<10; i++)
        jolitab[i] = i ;

    for (i=0; i<10; i++) {
        printf("La case %d est à l'adresse %p \n", i, &(jolitab[i])  ) ;
    }
    
    return 0 ;
}
$ gcc -o test main.c

$ ./test
La case 0 est à l'adresse 0x7ffe7bb73330
La case 1 est à l'adresse 0x7ffe7bb73334
La case 2 est à l'adresse 0x7ffe7bb73338
La case 3 est à l'adresse 0x7ffe7bb7333c
La case 4 est à l'adresse 0x7ffe7bb73340
La case 5 est à l'adresse 0x7ffe7bb73344
La case 6 est à l'adresse 0x7ffe7bb73348
La case 7 est à l'adresse 0x7ffe7bb7334c
La case 8 est à l'adresse 0x7ffe7bb73350
La case 9 est à l'adresse 0x7ffe7bb73354

Cependant, si jolitab[i] est un int … Quelle est la nature de jolitab ? Oui, oui, jolitab tout court, sans crochet ? Et bien c’est un pointeur. C’est même un pointeur qui contient l’adresse de la première case du tableau.

Et je vous le prouve :

#include <stdio.h>

int
main()
{
    int jolitab[10] ;
    int i ;

    for (i=0; i<10; i++)
        jolitab[i] = i ;

    for (i=0; i<10; i++) {
        printf("La case %d est à l'adresse %p \n", i, &(jolitab[i])  ) ;
    }

    printf("\n => jolitab vaut : %p\n", jolitab ) ;

    return 0 ;
}
$ gcc -o test main.c

$ ./test
La case 0 est à l'adresse 0x7fff82b60bb0
La case 1 est à l'adresse 0x7fff82b60bb4
La case 2 est à l'adresse 0x7fff82b60bb8
La case 3 est à l'adresse 0x7fff82b60bbc
La case 4 est à l'adresse 0x7fff82b60bc0
La case 5 est à l'adresse 0x7fff82b60bc4
La case 6 est à l'adresse 0x7fff82b60bc8
La case 7 est à l'adresse 0x7fff82b60bcc
La case 8 est à l'adresse 0x7fff82b60bd0
La case 9 est à l'adresse 0x7fff82b60bd4

 => jolitab vaut : 0x7fff82b60bb0

Car oui, depuis le début, vous manipulez sans le savoir des pointeurs quand vous utilisez un tableau ! Et c’est ce qu’il faut en retenir :

Un tableau, en C, ce n’est que l’adresse de sa première case !

Normalement, à ce stade de l’explication, un truc doit se déverrouiller dans votre tête. Un peu en mode “ouais, ça m’avait toujours eu l’air louche cette histoire de tableau …”. Et vous aviez raison !!!

Mais alors ? On m’a menti ?

Oui, un tout petit peu. Mais n’en voulez pas trop à votre prof et mettez vous à sa place : vous aviez besoin des tableaux, il était trop tôt pour vous faire plonger dans les pointeurs, que faire ?

Quand on transmet un tableau à une fonction, vous pouvez choisir deux syntaxes pour la déclaration de la fonction :

void fonction( int* tablo ) ;

ou

void fonction( int tablo[] ) ;

Les deux sont strictement équivalentes !

On vous a probablement dit que le C passait les arguments “par valeur”, mais que les tableaux étaient transmis “par référence”, et que donc on envoyait le tableau lui même, pas une copie. Faites moi plaisir : brulez cette partie de votre cours. C’est juste rendre compliquées des choses qui sont pourtant si simples !!!!!!

Quand vous transmettez un tableau à une fonction, vous transmettez juste l’adresse de sa première case. Et ca marche comme exactement toutes les autres variables : puisque c’est un passage de l’adresse, la fonction peut accéder au tableau.

D’ailleurs … puisqu’on en parle, qu’est-ce que c’est que ces crochets que nous utilisons ? C’est là aussi une écriture de pointeurs !

Ecrire jolitab[i] c’est exactement la même chose que d’écrire *(jolitab+i)

On prend ce qui est à l’adresse de base “jolitab”, décalée de i cases ! Elégant non ?

Regardez, je vous illustre ça de suite :

#include <stdio.h>

int
main()
{
    int jolitab[10] ;
    int i ;

    for (i=0; i<10; i++)
        jolitab[i] = i ;

    printf("\n => jolitab vaut : %p\n", jolitab ) ;
    printf("\n => L'adresse de jolitab[3] est : %p\n", &(jolitab[3]) ) ;
    printf("\n => jolitab[3] vaut : %d\n", jolitab[3] ) ;
    printf("\n => *(jolitab+3) vaut : %d\n", *(jolitab+3) ) ;

    return 0 ;
}
$ gcc -o test main.c

$ ./test

 => jolitab vaut : 0x7ffc8620eb00

 => L'adresse de jolitab[3] est : 0x7ffc8620eb0c

 => jolitab[3] vaut : 3

 => *(jolitab+3) vaut : 3

Houlà. Alors à ce stade là, quelques commentaires sont nécessaires, parce que c’est moins évident que ça n’en a l’air.

Si Jolitab vaut 0x0x7ffc8620eb00, est que l’adresse de Jolitab[3] est 0x7ffc8620eb0c, il y a un souci dans le calcul !

Oui, mais c’est parce qu’on est en train de faire de l’arithmétique sur pointeur ! Et je vais vous en parler de suite …

Arithmétique sur pointeur

Les pointeurs, en C, sont typés. Et cela est important. Admettons que j’ai un pointeur vers un entier, noté P :

#include <stdio.h>

int main()
{
    int a[5] = { 12, 21, 14, 13, 121 } ;
    int *P ;

    P = a ;         // Ici, on aurait aussi pu écrire P = &(a[0]) 

    printf("P contient l'adresse : %p\n", P ) ;
    printf("P pointe vers l'entier %d\n", *P ) ;

    return 0 ;
}

Quand je décale P, je veux qu’il pointe vers l’entier suivant non ? Il faut donc que je le décale de 4 Octets.

L’opération P+1 va justement tenir compte de cela en incrémentant bien l’adresse contenue dans P de 4 octets, soit la taille d’un int. Les opérations sur les pointeurs tiennent compte du type desdits pointeurs :

#include <stdio.h>

int main()
{
    int a[5] = { 12, 21, 14, 13, 121 } ;
    int *P ;

    P = a ;         

    printf("P contient l'adresse : %p\n", P ) ;
    printf("P pointe vers l'entier %d\n", *P ) ;

    printf("=> P = P + 1\n") ;
    P = P + 1 ;

    printf("P contient l'adresse : %p\n", P ) ;
    printf("P pointe vers l'entier %d\n", *P ) ;

    return 0 ;
}
$ gcc -o test main.c

$ ./test
P contient l'adresse : 0x7fff158dfd30
P pointe vers l'entier 12
=> P = P + 1
P contient l'adresse : 0x7fff158dfd34
P pointe vers l'entier 21

P est passé de 0x7fff158dfd30 à 0x7fff158dfd34 : l’incrément de l’adresse dans P est bien de 4 octet, soit la taille d’un int.

Graphiquement :

image

On peut vérifier que cela marche aussi pour d’autre types comme le double, par exemple :

#include <stdio.h>

int main()
{
    double a[5] = { 12, 21, 14, 13, 121 } ;
    double *P ;

    P = a ;         

    printf("P contient l'adresse : %p\n", P ) ;
    printf("P pointe vers le nombre %lf\n", *P ) ;

    printf("=> P = P + 1\n") ;
    P = P + 1 ;

    printf("P contient l'adresse : %p\n", P ) ;
    printf("P pointe vers le nombre %lf\n", *P ) ;

    return 0 ;
}
$ gcc -o test main.c

$ ./test
P contient l'adresse : 0x7ffdc445ce60
P pointe vers le nombre 12.000000
=> P = P + 1
P contient l'adresse : 0x7ffdc445ce68
P pointe vers le nombre 21.000000

Cette fois-ci, le pointeur étant de type double*, nous faisons des sauts de 8 Octets ! ( la taille d’un double … )

image

Hola, je vous vois venir vous ! vous allez me dire : “Et si le pointeur est non typé ? Si c’est un void* ?”

#include <stdio.h>

int main()
{
    int a[5] = { 12, 21, 14, 13, 121 } ;
    void *P ;

    P = (void*) &a ;         

    printf("P contient l'adresse : %p\n", P ) ;

    printf("=> P = P + 1\n") ;
    P = P + 1 ;

    printf("P contient l'adresse : %p\n", P ) ;

    return 0 ;
}
$ gcc -o test main.c

$ ./test
P contient l'adresse : 0x7ffe9b6d1eb0
=> P = P + 1
P contient l'adresse : 0x7ffe9b6d1eb1

Et bien c’est très simple : si le pointeur est non typé, l’incrémentation se fait octet par octet.

Vous savez maintenant comment manipuler les pointeurs. Tout cela n’est pas si compliqué, mais c’est très habilement conçu à mon humble avis. En C, l’articulation entre tableaux et adresses est particulièrement naturelle. (Avis 100% biaisé d’un amoureux du C : j’assume !)

Retour au tableau

Cette notation utilisant les crochets, tab[i], ou son équivalent *(tab+i), ne sont au final que des calculs d’adresses.

On peut d’ailleurs souligner deux choses. La première, et non des moindres, est que tout cela explique clairement pourquoi les cases d’un tableau sont indicées de 0 à N-1, et non pas de 1 à N comme dans d’autres langages. La case zéro, c’est l’adresse du début du tableau … décalée de zéro justement !

Une autre chose, plus méconnue celle-ci, est qu’on peut inverser la notation. Si tab[i] c’est *(tab+i) … alors c’est aussi *(i+tab) on est d’accord ? Oui, vos pupilles écarquillées et votre tension artérielle soudaine ne trompent pas, vous avez compris où je veux en venir.

Il est tout à fait légal, en C, d’utiliser i[tab] au lieu de tab[i]. Ca marche pareil ! Et le compilo l’accepte !!!!!!

Vous doutez ? Compilez donc ceci :

#include <stdio.h>

int main()
{
    int a[5] = { 12, 21, 14, 13, 121 } ;

    printf("a[3] %d\n", a[3] ) ;
    printf("3[a] %d\n", 3[a] ) ;

    return 0 ;
}

Gcc ne crache même pas le plus petit warning : ca compile et fonctionne parfaitement !

Par contre, ce n’est absolument pas lisible, et aucun programmeur sérieux ne fait ce genre de chose : je vous donne juste cette anecdote pour illustrer mon propos et vous montrer la logique de la chose. NE FAITES JAMAIS CA DANS LA VRAIE VIE. Vraiment. Sinon je bute un bébé chat et ce sera votre faute !

Jouer avec les pointeurs …

Ca, par contre, je vous autorise à le faire : utiliser astucieusement les pointeurs et les types.

Prenons un entier. C’est une donnée sur 4 Octets, vous êtes d’accord ?

Prenons maintenant un tableau de 4 chars … c’est aussi une donnée sur 4 octets, vous êtes d’accord ?

Puisqu’un tableau n’est qu’une adresse, on va utiliser un peu nos connaissances. Je vous laisse lire le code suivant :

#include <stdio.h>

int main() {
    int A ;
    unsigned char* tab ;

    A = 115200 ;
    tab = (unsigned char*)  &A ;         

    printf("Le 1er octet vaut %x\n", tab[0] ) ;
    printf("Le 2nd octet vaut %x\n", tab[1] ) ;
    printf("Le 3eme octet vaut %x\n", tab[2] ) ;
    printf("Le 4eme octet vaut %x\n", tab[3] ) ;

    return 0 ;

}
$ ./test
Le 1er octet vaut 0
Le 2nd octet vaut c2
Le 3eme octet vaut 1
Le 4eme octet vaut 0

Dans ce programme, A contient une valeur. Si nous passons par le pointeur tab, qui contient l’adresse de A, nous voyons ces 4 octets comme un tableau de 4 cases d’un octet chacune.

C’est toujours la même donnée, mais interprétée autrement !

D’après google, 115200 s’écrit en 4 octets dont les valeurs respectives sont : 00 01 C2 00. C’est bien le contenu des cases de tab.

Note : Il faut se rappeler que les PC sont des architectures little-endian ! C’est normal que les octets soient “à l’envers” :)

Le mot de la fin

Il se fait tard (1h20 du matin à ma montre) alors je vous propose de nous arrêter ici. J’espère que c’est clair, et n’hésitez pas, quand vous lirez ces lignes, à me dire ce que vous avez compris ou pas. Il existe plein d’exercices, ça et là sur le Net, que vous pouvez faire pour mieux maitriser la chose. Je vous y encourage vivement, car c’est en programmant que vous maitriserez tout ça !

A bientôt,

Rancune.