Version sans JavaScript, en lecture seule : les ateliers, les quiz et l’exécution du code n’y sont pas. Version complète · Catalogue · Se connecter

Support de cours · chapitre 2

Pointeurs et tableaux

Une adresse est une valeur comme une autre. Les opérateurs & et *, le passage par adresse, void *, les tableaux et ce qui les distingue des pointeurs, l'arithmétique des pointeurs, les chaînes et const.

Des adresses

La mémoire, une suite de cases

Pour le programme, la mémoire est une longue suite d'octets numérotés. Le numéro d'un octet est son adresse. Une variable int occupe quatre octets consécutifs (sur les machines actuelles). Son adresse est celle du premier. Pour suivre à la main ce que fait un programme, on peut écrire cette mémoire dans un tableau, une ligne par variable : c'est le chapitre supplémentaire qui suit celui-ci, « Le tableau de la mémoire ».

Deux opérateurs suffisent pour les manipuler :

  • &x donne l'adresse de x : c'est le référencement ;
  • *p désigne ce qui se trouve à l'adresse contenue dans p : c'est le déréférencement.
#include <stdio.h>

int main(void)
{
    int a = 42;
    int *p = &a;

    printf("a = %d, *p = %d\n", a, *p);
    *p = 7;
    printf("a = %d, *p = %d\n", a, *p);
    printf("&a = %p, p = %p\n", (void *)&a, (void *)p);
    return 0;
}
a = 42, *p = 42
a = 7, *p = 7
&a = 0x7ffd8d1802dc, p = 0x7ffd8d1802dc

p contient l'adresse de a. Écrire dans *p, c'est donc écrire dans a. Le format %p affiche une adresse ; il attend un void *, d'où la conversion. L'adresse affichée change d'une exécution à l'autre : le système place la pile à un endroit différent à chaque lancement, pour compliquer la tâche des attaquants.

Valeurs gauches

*p s'utilise de deux façons, selon sa place dans l'affectation :

  • à droite du = (ou dans n'importe quelle expression), *p vaut la valeur rangée à l'adresse : b = 1 + *p; ;
  • à gauche du =, *p désigne la case à cette adresse, où l'on range le résultat : *p = 7;.

On ne peut prendre l'adresse que d'une valeur gauche. &x, &t[4], &s.champ ont un sens ; &42 ou &(x + 1) n'en ont pas : ce sont des valeurs calculées, rangées nulle part.

char buf[32];
char *s = &buf[4];   /* s pointe sur le cinquième caractère de buf */

* et & sont inverses l'un de l'autre : *&a est a lui-même.

Le passage par adresse

En C, les arguments d'une fonction sont toujours passés par valeur : la fonction reçoit une copie. Cette fonction n'échange donc rien :

#include <stdio.h>

void echanger_faux(int a, int b)
{
    int c = a;
    a = b;
    b = c;
}

int main(void)
{
    int x = 0, y = 42;

    echanger_faux(x, y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}
x = 0, y = 42

Elle a échangé ses copies a et b, qui disparaissent au retour. Pour qu'une fonction modifie une variable de l'appelant, on lui passe l'adresse de la variable :

#include <stdio.h>

/* echanger(a, b) échange les entiers pointés par a et b */
void echanger(int *a, int *b)
{
    int c = *a;
    *a = *b;
    *b = c;
}

int main(void)
{
    int x = 0, y = 42;

    printf("x = %d, y = %d\n", x, y);
    echanger(&x, &y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}
x = 0, y = 42
x = 42, y = 0

Deux choses à remarquer. La fonction travaille sur *a et *b, les cases pointées. Et l'appelant écrit &x et &y : c'est à l'appel qu'on voit qu'une variable risque d'être modifiée. C'est ainsi qu'on traduit les « paramètres globaux » (passés par référence) du langage algorithmique. scanf("%d", &n) fonctionne de la même façon : scanf doit ranger ce qu'elle lit dans n, il lui faut son adresse.

Types de pointeurs

Déclarer

L'étoile se place devant le nom de la variable, et elle ne vaut que pour cette variable :

int x, *y, t[32];

déclare un entier x, un pointeur sur entier y, et un tableau de 32 entiers t. L'ordre n'importe pas : int *y, x, t[32]; déclare exactement la même chose.

Quand un pointeur représente à lui seul une donnée abstraite (une liste chaînée, un arbre), on le cache souvent derrière un alias de type, avec typedef. On y reviendra au chapitre 4.

Le pointeur nul

NULL (défini dans stddef.h, et aussi dans stdio.h et stdlib.h) est une adresse qui ne désigne aucune case. Un pointeur qui ne pointe sur rien doit valoir NULL. Déréférencer NULL est une erreur : sous Linux, le programme est tué par le système (« erreur de segmentation »).

Un pointeur non initialisé, lui, ne vaut pas NULL : il contient n'importe quoi, et le déréférencer écrit n'importe où.

void *

void * est le type des adresses « sans type » : une adresse, sans rien dire de ce qu'on trouve derrière. N'importe quel pointeur (vers une donnée) se convertit en void * et inversement, sans conversion explicite. On ne peut pas déréférencer un void * : on ne saurait pas combien d'octets lire, ni comment les interpréter.

L'exemple type est malloc, qui réserve de la mémoire sans savoir ce qu'on y mettra :

void *malloc(size_t size);

Son résultat se range directement dans un pointeur du type voulu : int *t = malloc(10 * sizeof (int));.

Tableaux

Un tableau n'est pas un pointeur

int t[10];

réserve dix entiers consécutifs, soit 40 octets. Il n'y a pas de pointeur caché quelque part : t est le nom de ce bloc. Mais dans presque toutes les expressions, t est converti en l'adresse de sa première case, &t[0], de type int *. On dit que le tableau se dégrade en pointeur. C'est pour cela qu'on peut écrire int *p = t;.

Cette conversion n'a pas lieu dans deux cas : sous sizeof, et sous &. On voit la différence avec sizeof, qui donne la taille en octets :

#include <stdio.h>

void f(int t[])
{
    printf("dans f : sizeof t = %zu\n", sizeof t);
}

int main(void)
{
    int t[10];
    int *p = t;

    printf("sizeof t = %zu\n", sizeof t);
    printf("sizeof p = %zu\n", sizeof p);
    printf("nombre de cases : %zu\n", sizeof t / sizeof t[0]);
    f(t);
    return 0;
}
sizeof t = 40
sizeof p = 8
nombre de cases : 10
dans f : sizeof t = 8

Dans main, t est bien un tableau de 40 octets, et p un pointeur de 8 octets (sur une machine 64 bits). Mais dans f, t ne mesure plus que 8 octets : un paramètre déclaré comme un tableau est un pointeur. void f(int t[]) et void f(int *t) sont exactement la même déclaration. Une fonction ne reçoit jamais un tableau, seulement l'adresse de sa première case ; elle ne peut donc pas connaître sa taille, il faut la lui passer. gcc -Wall le rappelle d'ailleurs :

taille.c:5:48: warning: 'sizeof' on array function parameter 't' will return size of 'int *'

C'est pourquoi ce programme ne compile pas avec -Werror : il est là pour montrer le piège.

L'accès aux cases

L'accès t[i] est défini par l'arithmétique des pointeurs : t[i] est, par définition, *(t + i). Il marche donc aussi bien sur un tableau que sur un pointeur. Deux conséquences :

  • Aucun contrôle n'est fait sur les indices. t[10] sur un tableau de dix cases lit (ou écrit) la mémoire qui suit le tableau.
  • Un indice négatif est accepté par le compilateur : p[-1] est *(p - 1), la case qui précède. C'est correct si p pointe au milieu d'un tableau, et un comportement indéfini sinon.

Tableaux à deux dimensions

int t[4][3] est un tableau de 4 lignes, chacune un tableau de 3 entiers. Les 12 entiers sont rangés ligne par ligne, à la suite :

#include <stdio.h>

/* somme des cases d'une matrice de h lignes et 3 colonnes */
int somme_matrice(int t[][3], size_t h)
{
    int r = 0;
    for (size_t j = 0; j < h; j++)
        for (size_t i = 0; i < 3; i++)
            r += t[j][i];
    return r;
}

int main(void)
{
    int t[4][3];
    int n = 0;

    for (size_t y = 0; y < 4; y++)
        for (size_t x = 0; x < 3; x++)
            t[y][x] = ++n;

    for (size_t y = 0; y < 4; y++) {
        for (size_t x = 0; x < 3; x++)
            printf("%02d ", t[y][x]);
        printf("\n");
    }
    printf("\nsomme = %d\n\n", somme_matrice(t, 4));

    /* la même mémoire, lue comme 12 entiers à la suite */
    int *lin = &t[0][0];
    for (size_t k = 0; k < 12; k++)
        printf("%02d ", lin[k]);
    printf("\n");
    return 0;
}
01 02 03 
04 05 06 
07 08 09 
10 11 12 

somme = 78

01 02 03 04 05 06 07 08 09 10 11 12 

Pour trouver t[j][i], le compilateur calcule le décalage j * 3 + i : il doit connaître la largeur d'une ligne. C'est pourquoi le paramètre s'écrit int t[][3] : la première dimension peut rester vide (le paramètre est un pointeur sur des lignes de 3 entiers), pas la seconde. Une fonction écrite ainsi ne sait traiter que des matrices de 3 colonnes.

Pour une matrice dont les deux dimensions ne sont connues qu'à l'exécution, on range tout dans un tableau à une dimension, et on calcule le décalage soi-même :

/* la case (x, y) d'une matrice de largeur w, rangée ligne par ligne */
int lire_case(const int t[], size_t w, size_t x, size_t y)
{
    return t[x + y * w];
}

La largeur w est nécessaire, et suffisante, pour retrouver chaque case.

L'arithmétique des pointeurs

Ajouter un entier n à un pointeur le fait avancer de n éléments, pas de n octets : si p est un int *, p + 1 est l'adresse de l'entier suivant, 4 octets plus loin.

#include <stdio.h>

int main(void)
{
    int t[4] = {10, 20, 30, 40};
    int *p = t;
    char *c = (char *)t;

    printf("p     = %p\n", (void *)p);
    printf("p + 1 = %p\n", (void *)(p + 1));
    printf("c + 1 = %p\n", (void *)(c + 1));
    printf("*(p + 2) = %d, t[2] = %d, 2[t] = %d\n", *(p + 2), t[2], 2[t]);
    printf("&t[3] - &t[0] = %td\n", &t[3] - &t[0]);
    for (int *q = t; q < t + 4; q++)
        printf("%d ", *q);
    printf("\n");
    return 0;
}
p     = 0x7ffe6e904bb0
p + 1 = 0x7ffe6e904bb4
c + 1 = 0x7ffe6e904bb1
*(p + 2) = 30, t[2] = 30, 2[t] = 30
&t[3] - &t[0] = 3
10 20 30 40 
  • p + 1 avance de 4 octets, c + 1 d'un seul : la même adresse, vue comme int * ou comme char *.
  • *(p + 2) et t[2] sont la même chose. Et puisque t[2] est *(t + 2), c'est aussi *(2 + t), donc 2[t] : une curiosité à ne jamais écrire, mais qui montre bien que [] n'est qu'un raccourci.
  • La différence de deux pointeurs dans un même tableau est un nombre d'éléments (de type ptrdiff_t, affiché avec %td).
  • On peut parcourir un tableau avec un pointeur, jusqu'à l'adresse qui suit la dernière case, t + 4. Cette adresse-là a le droit d'exister et d'être comparée, pas d'être déréférencée.

Chaînes de caractères

Un tableau de caractères terminé par zéro

Une chaîne, en C, est une suite de caractères terminée par le caractère nul '\0' (l'octet 0). Il n'y a pas de type chaîne : on manipule un tableau de char, ou un pointeur sur son premier caractère. Toutes les fonctions de string.h (strlen, strcpy, strcmp…) avancent jusqu'au '\0' : une chaîne sans lui fait lire la mémoire qui suit, jusqu'à tomber par hasard sur un octet nul.

"bonjour" occupe donc 8 octets : sept lettres et le zéro final.

Littéral ou tableau

char *s1 = "bonjour";
char s2[] = "bonjour";

Ces deux lignes se ressemblent et ne font pas du tout la même chose.

  • s1 est un pointeur, initialisé avec l'adresse d'une chaîne littérale. Le littéral est rangé par le compilateur dans une zone en lecture seule (chapitre 3). On peut lire s1[0], mais y écrire est un comportement indéfini, et sous Linux le programme est tué.
  • s2 est un tableau de 8 caractères, initialisé par copie du littéral. Il appartient au programme, on peut le modifier.
#include <stdio.h>

int main(void)
{
    char *s1 = "bonjour";
    char s2[] = "bonjour";

    s2[0] = 'B';
    printf("%s %s\n", s1, s2);
    printf("sizeof s1 = %zu, sizeof s2 = %zu\n", sizeof s1, sizeof s2);
    s1[0] = 'B';
    printf("%s\n", s1);
    return 0;
}
bonjour Bonjour
sizeof s1 = 8, sizeof s2 = 8
Segmentation fault (core dumped)

Les deux sizeof valent 8, mais par coïncidence : 8 octets pour le pointeur s1, 8 caractères pour le tableau s2. Le compilateur ne dit rien de s1[0] = 'B' : pour des raisons historiques, un littéral a le type char[], pas const char[]. L'option -Wwrite-strings fait de lui un const char[], et gcc signale alors l'initialisation de s1.

const

const promet qu'on ne modifiera pas une valeur, et le compilateur fait respecter la promesse.

const char *s = "chaine";
s[0] = 'C';
error: assignment of read-only location '*s'

const char *s se lit « s est un pointeur sur des char constants » : c'est ce qui est pointé qui ne doit pas changer. s elle-même peut changer, et pointer ailleurs. (Un pointeur qui ne peut pas changer s'écrit char *const s, plus rare.)

Un paramètre const dit au lecteur, dès la signature, que la fonction ne modifie pas ce qu'on lui passe :

size_t longueur(const char *s)
{
    size_t n = 0;
    while (s[n] != '\0')
        n++;
    return n;
}