« Programmation C/Pointeurs » : différence entre les versions

Un livre de Wikilivres.
Contenu supprimé Contenu ajouté
Balise : Révoqué
Ligne 258 : Ligne 258 :
void echange(int * a, int *b)
void echange(int * a, int *b)
{
{
int tmp;
int tmp=0,
tmp = *a;
*t[3]={&tmp,a,b};
*a = *b;
unsigned short int i;
*b = tmp;
for (i=0;i<3;i++)
*t[i] = *t[(i!=2)*(i+1)];
/*tmp = *a; <=> *t[0]=*t[1] */
/**a = *b; <=> *t[1]=*t[2] */
/**b = tmp; <=> *t[2]=*t[0] */
}
}



Version du 23 mai 2012 à 19:17


Dans cette section, nous allons présenter un mécanisme permettant de manipuler les adresses, les pointeurs. Un pointeur a pour valeur l'adresse d'un objet C d'un type donné (un pointeur est typé). Ainsi, un pointeur contenant l'adresse d'un entier sera de type pointeur vers entier.

Usage et déclaration

L'opérateur & permet de connaitre l'adresse d'une variable, on dira aussi la référence. Toute déclaration de variable occupe un certain espace dans la mémoire de l'ordinateur. La référence permet de savoir où cet emplacement se trouve. En simplifiant à l'extrême, on peut considérer la mémoire d'un ordinateur comme une gigantesque table d'octets. Quand on déclare une variable de type int, elle sera allouée à un certain emplacement (ou dit autrement : un indice, une adresse ou une référence) dans cette table. Un pointeur permet simplement de stocker une référence, il peut donc être vu comme un nombre allant de 0 à la quantité maximale de mémoire dont dispose votre ordinateur (moins un, pour être exact).

Un pointeur occupera habituellement toujours la même taille (occupera la même place en mémoire), quelque soit l'objet se trouvant à cet emplacement. Il s'agit en général de la plus grande taille directement gérable par le processeur : sur une architecture 32bits, elle sera de 4 octets, sur une architecture 64bits, 8 octets, etc. Le type du pointeur ne sert qu'à renseigner comment sont organisées les données suivant l'adresse référencée par le pointeur. Ce code, par exemple, affiche la référence d'une variable au format hexadécimal :

int i; 	 

printf("%p\n", &i);

Pouvoir récupérer l'adresse n'a d'intérêt que si on peut manipuler l'objet pointé. Pour cela, il est nécessaire de pouvoir déclarer des pointeurs, ou dit autrement un objet pouvant contenir des références. Pour cela on utilise l'étoile (*) entre le type et le nom de la variable pour indiquer qu'il s'agit d'un pointeur :

T * pointeur, * pointeur2, /* ..., */ * pointeurN;

Déclare les variables pointeur, pointeur2, ..., pointeurN de type pointeur vers le type T. À noter la bizarrerie du langage à vouloir associer l'étoile à la variable et non au type, qui oblige à répéter l'étoile pour chaque variable.

/* Ce code contient une déclaration volontairement confuse */
int * pointeur, variable;

Cet exemple de code déclare un pointeur sur un entier de type int et une variable de type int. Dans un vrai programme, il est rarement possible d'utiliser des noms aussi triviaux, aussi il est recommandé de séparer la déclaration des variables de celles des pointeurs (ou d'utiliser l'instruction typedef, qui, elle, permet d'associer l'étoile au type), la lisibilité du programme sera légèrement améliorée.

Il est essentiel de bien comprendre ce qui a été déclaré dans ces exemples. Chaque pointeur peut contenir une référence sur un emplacement de la mémoire (un indice dans notre fameuse table). On peut obtenir une référence (ou un indice) avec l'opérateur & (ou allouer une référence soit-même avec des fonctions dédiées, c.f la section suivante). Cet opérateur transforme donc une variable de type T en un pointeur de type T *. Insistons sur le terme variable, car évidemment des expressions telles que '&23435' ou '&(2 * a / 3.)' n'ont aucun sens, dans la mesure où les constantes et expressions du langage n'occupent aucun emplacement susceptible d'intéresser votre programme.

Ce code, par exemple, affiche la référence d'une variable dans un format défini par l'implémentation (qui peut être hexadécimal, ou une combinaison "segment:offset", par exemple) :

int i;

printf("%p\n", &i);

Il ne faut pas oublier que, comme toutes les variables locales en C, un pointeur est à l'origine non initialisé. Une bonne attitude de programmation est de s'assurer que lorsqu'il ne pointe pas vers un objet valide, sa valeur est mise à zéro (ou NULL, qui est déclaré entre autre dans <stdio.h>).

L'arithmétique des pointeurs

L'arithmétique associée aux pointeurs est sans doute ce qui a valu au C sa réputation « d'assembleur plus compliqué et plus lent que l'assembleur ». On peut très vite construire des expressions incompréhensibles avec les opérateurs disponibles. Dans la mesure du possible, il est conseillé de se limiter à des expressions simples, quitte à les décomposer, car la plupart des compilateurs savent très bien optimiser un code C.

Déréférencement

Il s'agit de l'opération la plus simple sur les pointeurs. Comme son nom l'indique, il s'agit de l'opération réciproque au référencement (&). L'opérateur associé est l'étoile (*), qui est aussi utilisé pour déclarer un type pointeur. Cet opérateur permet donc de transformer un pointeur de type T *, en un objet de type T, les opérations affectant l'objet pointé :

int variable = 10;
int * pointeur = &variable;

*pointeur = 20; /* Positionne 'variable' à 20 */

Ici, pointeur contient une adresse valide, celle de variable ; son déréférencement est donc possible. Par contre, si pointeur était une variable locale non initialisée, son déréférencement provoquerait à coup sûr un arrêt brutal de votre programme.

Vous obtiendrez le même résultat, si pointeur est initialisé à NULL. Cette adresse est invalide et toute tentative de déréférencement se soldera par un arrêt du programme.

Arithmétique de base

L'arithmétique des pointeurs s'apparente à celle des entiers, mais il est important de comprendre la distinction entre ces deux concepts.

Les opérations arithmétiques permises avec les pointeurs sont :

  • Addition / soustraction d'une valeur entière à un pointeur (on avance / recule d'un nombre de cases mémoires égal à la taille du type T) : le résultat est donc un pointeur, de même type que le pointeur de départ. Il faut bien faire attention avec ce genre d'opération à ne pas sortir du bloc mémoire, car le C n'effectuera aucun test pour vous. Considérez l'exemple suivant :
    /* Parcours les éléments d'un tableau */
    int tableau[N];
    int * p;
    
    for (p = tableau; p < &tableau[N]; p ++)
    {
         /* ... */
    }
    

    Normalement un tableau de N cases permet d'être itéré sur les indices allant de 0 à N - 1, inclusivement. L'expression &tableau[N] fait référence la case mémoire non allouée immédiatement après le plus grand indice, donc potentiellement source de problème. Toutefois, par exception pour le premier indice après le plus grand, C garantit que le résultat de l'expression soit bien défini. Bien sûr, il ne faut pas déréférencer ce pointeur.

    À noter qu'à l'issue de la boucle, p pointera sur la N+1ème case du tableau, donc hors de l'espace alloué. Le C autorise tout à fait ce genre de pratique, il faut juste faire attention à ne pas déréférencer le pointeur à cet endroit.

  • Soustraction de deux pointeurs de même type (combien d'objet de type T y a t-il entre les deux pointeurs) : le résultat est donc un entier, de type ptrdiff_t.
    Avertissement Ce code contient une erreur volontaire !
    int       autre_tableau[3];
    int       tableau[10];
    int *     p     = &tableau[5];  /* p pointe sur le 6e élément du tableau */
    int *     q     = &tableau[3];  /* q pointe sur le 4e élément du tableau */
    ptrdiff_t diff1 = p - q;        /* diff1 vaut 2 */
    ptrdiff_t diff2 = q - p;        /* diff2 vaut -2 */
    
    q = &autre_tableau[2];
    ptrdiff_t dif3 = p - q; /* Erreur ! */
    

    Dans cet exemple, les deux premières soustractions sont définies, car p et q pointent sur des éléments du même tableau. La troisième soustraction est indéfinie, car on utilise des adresses d'éléments de tableaux différents.

    Notons que l'opérateur [] s'applique toujours à une opérande de type entier et une autre de type pointeur. Lorsqu'on écrit tableau[i], il y a en fait une conversion de tableau à pointeur avec l'application de l'opérateur []. On peut donc bien sûr utiliser l'opérateur [] avec un pointeur pour opérande :

    int   a;
    int b;
    int * p = &a; /* On peut accéder à la valeur de 'a' via 'p[0]' ou '*p' */
    
    /* p[1] est indéfini - n'espérez pas accéder à la valeur de b depuis l'adresse de a */
    

Arithmétique avec effet de bord

C'est sans doute ce qui a donné des sueurs froides à des générations de programmeurs découvrant le C : un usage « optimisé » de la priorité des opérateurs, le tout imbriqué dans des expressions à rallonge. Par exemple 'while( *d++ = *s++ );', pour copier une chaine de caractères.

En fait, en décomposant l'instruction, c'est nettement plus simple qu'il ne parait. Par exemple :

int i;
int * entier;

/* ... */

i = *entier++; /* i = *(entier++); */

Dans ce cas de figure, l'opérateur d'incrémentation ayant priorité sur celui de déréférencement, c'est celui-ci qui sera appliqué en premier. Comme il est postfixé, l'opérateur ne prendra effet qu'à la fin de l'expression (donc de l'affectation). La variable i sera donc tout simplement affectée de la valeur pointée par entier et après cela le pointeur sera incrémenté. Voici les différents effets suivant les combinaisons de ces deux opérateurs :

i = *++entier; /* Incrémente d'abord le pointeur, puis déréférence la nouvelle adresse pointée */
i = ++*entier; /* Incrémente la valeur pointée par "entier", puis affecte le résultat à "i" */
i = (*entier)++; /* Affecte la valeur pointée par "entier" et incrémente cette valeur */

On peut évidemment complexifier les expressions à outrance, mais privilégier la compacité au détriment de la clarté et de la simplicité dans un hypothétique espoir d'optimisation est une erreur de débutant à éviter.


Tableaux dynamiques

Un des intérêts des pointeurs et de l'allocation dynamique est de permettre de décider de la taille d'une variable au moment de l'exécution, comme par exemple pour les tableaux. Ainsi pour allouer un tableau de n entiers (n étant connu à l'exécution), on déclare une variable de type pointeur sur entier à laquelle on alloue une zone mémoire correspondant à n entiers :

int * alloue_tableau(int n, size_t taille)
{
    return malloc(n * taille);
}

/* Ailleurs dans le programme */
int * tableau = alloue_tableau(256, sizeof *tableau);
if (tableau  != NULL)
{
    /* opérations sur le tableau */
    /* ... */
    free( tableau );
}

Cet exemple alloue un tableau de 256 cases. Bien que la variable soit un pointeur, il est dans ce cas permis d'accéder aux cases de 0 à 255, soit entre les adresses &tableau[0] et &tableau[255], incluses.

Tableaux dynamiques à deux dimensions

Tout comme on pouvait allouer des tableaux statiques à plusieurs dimensions, on peut allouer des tableaux dynamiques à plusieurs dimensions. Pour ce faire, on commence là-aussi par déclarer un pointeurs approprié : un pointeur sur des pointeurs (etc.) sur des types. Pour déclarer un tableau dynamique d'entiers à deux dimensions :

int ** matrice;

L'allocation d'un tel objet va se dérouler en plusieurs étapes (une par étoile), on alloue d'abord l'espace pour un tableau de pointeurs vers entier. Ensuite, on alloue pour chacun de ces tableaux l'espace pour un tableau d'entiers. Si on veut une matrice 4x5 :

#define LIGNES 4
#define COLONNES 5
int i;
int ** matrice = malloc(sizeof *matrice * LIGNES);

for (i = 0; i < LIGNES; i++)
{
        matrice[i] = malloc(sizeof **matrice * COLONNES);
}

Il ne faut jamais oublier de libérer la mémoire allouée précédemment. Ainsi, à tout appel de malloc doit correspondre un appel de free Pour libérer l'espace alloué ci-dessus, on procède de manière inverse, en commençant par libérer chacune des lignes du tableau, puis le tableau lui même :

for(i = 0; i < LIGNES; i++)
{
        free(matrice[i]);
}
free(matrice);

Tableaux dynamiques à trois dimensions

Voici maintenant un exemple d'une fonction créant un tableau à trois dimensions avec surtout, et c'est très important, les tests des valeurs de retour des fonctions malloc :

int *** malloc_3d(int nb_tableau, int lignes, int colonnes)
{  
    int i;
    int j;

    int***t = malloc(sizeof(*t) * nb_tableau); 
    /* première dimension */
    if (t==NULL) 
        { 
            printf ("Impossible d'initialiser avec malloc\n" ); 
            exit (-1); 
        }
        
    for (i=0;i< nb_tableau;i++) {
        t[i] = malloc(sizeof(**t) * lignes); 
        /* deuxième dimension */
        if (t[i]==NULL) { 
            printf ("Impossible d'initialiser avec malloc\n" ); 
            exit (-1); 
        }
        for (j=0;j<lignes;j++) {
            /* troisième dimension */
            t[i][j] = malloc(sizeof(***t) * colonnes); 
            if (t[i][j]==NULL) 
                { 
                    printf ("Impossible d'initialiser avec malloc\n" ); 
                    exit (-1); 
            }
        }
    }    
    return t;
}

Utilisation des pointeurs sur des tableaux particuliers

Il est possible avec un pointeur de lire/parcourir les éléments d'une structure. Chaque éléments d'une structure utilise un espace qui permet de calculer des déplacements.

#include <stdio.h>
#include <string.h>
struct s{
 int a;
 int b;
 char *s;
}s;
int
main(void){
 struct s st={1,1,"salut"};
 void *p=&st;
 printf ("%s\n",(char *)((void **)p)[1]);
 /*<==OU==>*/
 p+=2*sizeof(int);
 printf("%s\n",(char *)((void **)p)[0]);
 /*<=DE LA MEME MANIERE=>*/
 p-=2*sizeof(int);
 /*====================================*/
 memset(p,-1,2*sizeof(int));
 printf("%i,%i\n",st.a,st.b);
 memset((int *)p,0,2*sizeof(int));
 printf("%i,%i\n",st.a,st.b);
 ((int *)p)[0]=1;
 ((int *)p)[1]=0;
 printf("%i,%i\n",st.a,st.b);
 printf("%s\n",st.s);
 return 0;
}

Utilisation des pointeurs pour passer des paramètres par adresse

Toutes les variables en C, à l'exception des tableaux, sont passés par valeurs aux paramètres des fonctions. C'est à dire qu'une copie est effectuée sur la pile d'appel. Si bien que toutes les modifications de la variable effectuées dans la fonction seront perdues une fois de retour à l'appelant. Or, il y a des cas où l'on aimerait bien pouvoir modifier une variable passée en paramètre et que ces modifications perdurent dans la fonction appelante. C'est un des usages des paramètres par adresse : permettre la modification d'une variable de l'appelant, comme dans l'exemple suivant :

#include <stdio.h>
/* Ce code échange le contenu de deux variables */
void echange(int * a, int *b)
{
        int tmp=0,
                  *t[3]={&tmp,a,b};
        unsigned short int i;
        for (i=0;i<3;i++)
             *t[i] = *t[(i!=2)*(i+1)];
        /*tmp = *a;  <=> *t[0]=*t[1] */
        /**a = *b;  <=>  *t[1]=*t[2] */
        /**b = tmp; <=>  *t[2]=*t[0] */
}

int main(void)
{
    int a = 5;
    int b = 2;

    printf("a = %d, b = %d.\n", a, b);

    /* On passe à 'echange' les adresses de a et b. */
    echange(&a, &b);

    printf("a = %d, b = %d.\n", a, b);

    echange(&a, &b);

    printf("a = %d, b = %d.\n", a, b);

    return 0;
}

Ce passage par adresse est extrêmement répandu pour optimiser la quantité de données qui doit transiter sur la pile d'appel (qui est, sur beaucoup de systèmes, de taille fixe). En fait, même si la variable ne doit pas être modifiée, on utilise quand même un passage par adresse, juste pour éviter la copie implicite des variables autres que les tableaux. Ceci est particulièrement intéressant avec les structures, puisque celles-ci ont tendance à être assez imposantes, et cela ne nuit pas trop la lisibilité du programme.

Tableaux dynamiques et passage de tableaux comme arguments d'une fonction

Comme on vient de le voir, l'intérêt principal d'une allocation dynamique est de pouvoir lancer son programme sans connaître la taille du tableau qu'on utilisera; celle-ci sera établie en cours de fonctionnement. Cependant, un autre intérêt du tableau dynamique est de pouvoir être passé comme argument d'une fonction pour des tableaux à plusieurs dimensions.

En reprenant la fonction malloc_3d() vue précédemment, on peut écrire :

int fonction_3d (int ***tab);

int main(int argc, char **argv)
{
   /* Tableau dynamique créé avec des malloc() */
   int ***tab = malloc_3d(2, 10, 10);

   fonction_3d (tab);

    return EXIT_SUCCESS;
}
/* fonction recevant le tableau en 3d */
void fonction_3d (int ***tab)
{
  /* On peut utiliser ici la notation tab[i][j][k] en veillant à 
     ce que i, j, k ne sortent pas des bornes du tableau -> dans 
     cet exemple tab[3][9][9] est illégal alors que tab[1][3][7] 
     peut être utilisé.
     On ne pourra pas savoir si on sort du tableau !
  */
}

Pointeurs vers fonctions

Les pointeurs vers les fonctions sont un peu spéciaux, parce qu'ils n'ont pas d'arithmétique associée (car une telle arithmétique n'aurait pas beaucoup de sens). Les opérations permises avec les pointeurs sur fonctions sont en fait relativement limitées :

type_retour (*pointeur_fonction)(liste_paramètres);

Déclare pointeur_fonction, un pointeur vers une fonction prenant liste_paramètres comme paramètres et renvoyant type_retour. Le parenthésage est ici obligatoire, sans quoi l'étoile se rattacherait au type de retour. Pour faire pointer un pointeur vers une fonction, on utilise une affectation « normale » :

pointeur_fonction = &fonction;
/* Qui est en fait équivalent à : */
pointeur_fonction = fonction;

fonction est compatible avec le pointeur (mêmes paramètres et valeur de retour). Une fois que le pointeur pointe vers une fonction, on peut appeler cette fonction :

(*pointeur_fonction)(paramètres);
/* Ou plus simplement, mais moins logique syntaxiquement */
pointeur_fonction(paramètres);