Les pointeurs

Dernière mise à jour : 29 février 2012


Introduction

▶ La manipulation des pointeurs constitue, sans doute, la principale spécificité du langage C, du fait de la manipulation fine des adresses logiques en mémoire qu'elle autorise.
L'utilisation des pointeurs permet d'avoir un accès à la mémoire au travers d'adresses (logiques) plurôt qu'au travers d'dentifcateurs.
Il est ainsi possible de se déplacer dans la mémoire, ce qui permet des optimisations sur son utilisation.

Elle est aussi la source de pas mal de problèmes et de la terminaison accidentelle de nombre de programmes avec l'un des célèbres messages « Memory fault.Core dumped », « Segmentation fault.Core dumped » ou « Bus error.Core dumped ».

▶ Un pointeur (ce peut être une variable ou une constante) correspond à l'adresse d'un emplacement en mémoire. Une telle adresse peut être celle d'une variable ou d'un espace ne correspondant pas directement à une variable : il peut s'agir de la valeur d'une expression de pointeur ou de l'adresse d'un espace alloué dynamiquement suite à une demande d'allocation explicite.

▶ Alors qu'en Java, le concept de pointeur apparaît de fait de manière implicite avec le concept de référence sur objet d'une classe définie (à opposer aux variables de types primitifs qui s'apparentent aux variables du langage C), le langage C permet des manipulations explicites (mis à part éventuellement pour les tableaux où elle est cachée si on les utilise par indexation classique).

Le langage C permet ainsi de réaliser, de manière explicite, c'est-à-dire au travers d'opérateurs spécifiques :

▶ En C, les pointeurs jouent un rôle central :


Adresses, variables et pointeurs

▶ Ce n'est un secret pour personne : à toute variable correspond un espace en mémoire, auquel on peut accéder par une adresse (au bout du compte, ce qui importe c'est l'adresse physique qui permet à l'électronique d'accéder effectivement aux composants, mais cette adresse physique peut se trouver cachée dans une adresse logique qui doit être traduite en une adresse physique ... mais cela est un autre cours).
Du type de la variable, se déduisent la taille de cet espace et la manière dont la suite de bits qui le compose doit être interprétée.

▶ Tout programmeur Java, débutant et un tant soit peu curieux, a pu constater qu'en demandant l'affichage de la valeur d'une variable sur une classe Bidule qu'il a lui-même définie, il obtenait, (après avoir créé un objet et affecté le résultat de cette opération à la variable) sans rien demander de particulier (un simple System.out.print), une information du genre :
     Bidule@192d342
ne correspondant pas nécessairement à ce qu'il espérait.
Cette information fournit le type de l'objet et une information permettant de le localiser en mémoire : son adresse exprimée sous forme hexadécimale.

L'opérateur d'adressage ou référencement du langage C

▶ En langage C, les choses sont quelque peu différentes. Pour obtenir (our autre chose que des tableaux) le même type d'information que celui qu'on obtient en Java, il faut le demander explicitement, et de plus seule l'adresse pourra être obtenue. Le typage de la zone mémoire n'est qu'une histoire d'interprétation de l'application, qui peut changer à loisir la vue qu'elle a d'un même espace au travers de l'opération de coercition (cast).

▶ Etant donnée une variable x quelconque sur un type T, l'opérateur qui permet d'accéder à l'adresse de l'espace associé à cette variable est l'opérateur d'adressage ou de référencement qui est noté « & ».

Le cas des variables scalaires, structures et unions

Dans le programme suivant, on affiche les adresses de différentes variables de différents types (scalaire ou structure) dans différentes classes d'allocations (statique ou pile).
Le format utilisé dans le print est %p, format dédié à l'affichage des adresses sous forme hexadécimale
Les résultats illustrent en particulier :

  • l'existence de plage d'adresses différentes pour ce qui est statique et ce qui est sur la pile;
  • l'adresse d'une variable structure est celle de son premier champ.
  • --> cat adresses_variables.c
    #include <stdio.h>
    #include <stdlib.h>
    int a; // une variable globale a de type int
    /* définition d'une structure et d'une variable initialisée */
    struct{int a, b;} st = {3, 4};
    int main( ) {
        int b = 10;
        static int c = 20;
        printf("adresse de a : %p\n", &a);
        printf("adresse de b : %p\n", &b);
        printf("adresse de c : %p\n", &c);
        printf("adresse de st : %p\n", &st);
        printf("adresse de st.a : %p\n", &st.a);
        printf("adresse de st.b : %p\n", &st.b);
        return EXIT_SUCCESS; // return 0;
    }
    --> ./adresses_variables
    adresse de a : 0x203c
           <-- adresse de la variable a (zone statique)
    adresse de b : 0xbffff8dc
       <-- adresse de la variable b (sur la pile)
    adresse de c : 0x201c
           <-- adresse de la variable c (zone statique)
    adresse de st : 0x2014
          <-- adresse de la variable st (zone statique)
    adresse de st.a : 0x2014
        <-- adresse de st.a = adresse de st
    adresse de st.b : 0x2018
        <-- adresse de st.b
    -->

Le cas des tableaux

Les résultats du programme suivant :

    --> cat adresse_tableau.c
    #include <stdio.h>
    #include <stdlib.h>
    int tab[4] = {1,2,3,4};
    int main( ) {
        printf("%p\n", tab);
        printf("%p\n", &tab[0]);
        printf("%p\n", &tab);
        return EXIT_SUCCESS; // return 0;
    }
    --> ./adresse_tableau
    0x2014
    0x2014
    0x2014
    -->
font apparaître que tab, &tab[0] et &tab sont des pointeurs vers la même adresse en mémoire.

Cependant en examinant les choses d'un peu plus près on peut observer des diifférences entre ces trois valeurs du point de vue de leur type et taille (et par suite, en anticipant sur ce que nous dirons plus loin, sur leur arithmétique, par exemple par la valeur obtenue en ajoutant 1).
Le tableau suivant résume les choses :

 expression  valeur
 (format %p
 type   taille 
 (sizeof *
 valeur + 1 
tab  0x600940 pointeur sur int   4  0x600944
&tab[0]  0x600940 pointeur sur int    4  0x600944
&tab  0x600940 pointeur sur tableau de 16 int    16  0x600950

Types pointeurs, définitions et déclaration de variables, constantes

Les types pointeurs

▶ Etant donné un type T quelconque, le type pointeur sur T s'écrit « T* »

▶ Ainsi, la définition d'une variable p du type ««pointeur sur T », ou de manière abrégée « pointeur sur T » correspond à l'instruction
          T *p;

De fait, cette écriture ne fait qu'exprimer que la variable p pointe sur une zone mémoire dont la valeur *p est de type T.

▶ Le type « void * joue un rôle particulier : il définit un type de pointeur générique. Ce type de pointeur est compatible avec tous les autres types de pointeurs : un pointeur de ce type peut pointer vers n'importe quel type. Il s'agit d'un type à part entière (et non pas d'un pointeur vers quelque chose de type void, ce qui n'existe pas).

Ce type joue un rôle central dans le prototypage des fonctions pour désigner :

▶ Le programme suivant donne quelques exemples de ce que nous venons de décrire :

Les résultats permettent d'observer que :

▶ La définition de pointeurs se décline bien évidemment et enrichit l'ensemble des types de structures de données définissables.
Les définitions qui s'en suivent peuvent être compliquées à décrypter . L'interpr&eeacute;tation et donc la compréhension de telles définitions se fonde sur la priorité des opérateurs qui y sont utilisés.

▶ Quelques exemples de définitions de types :

  • « char *p[] ». La variable p est du type « tableau de pointeurs sur char ». En effet dans l'interprétation de ce qu'est « *p[i] », l'indexation est prioritaire : ainsi « p[i] » est évalué en premier et est de type « char * »;
  • « int (*p)(int, int) » . La variable p est du type « pointeur sur fonction ayant deux paramètres de type int renvoyant un int.
    En effet dans une utilisation de p, on obtient une expression de la forme « (*p)(a,b) » et pour l'évaluer :
    • on commence par déréférencer p : p est donc un pointeur
    • on fait ensuite un appel de la fonction obtenue fonction avec deux paramètres entiers : p est donc un pointeur sur une fonction ayant deux paramètres de type int;
    • le type du résultat est un int.
  • « char *(*p(void))[] ». Si on considère une utilisation de p, on obtient une expression « (*p())[j] » qui est de type char.
    • l'évaluation commence par un appel de fonction sans paramètre qui est prioritaire par rapport à l'opérateur *. Cela signifie donc que p est une fonction
    • on applique ensuite l'opérateur *. cela signifie que la fonction renvoie un pointeur
    • on applique ensuite l'indexation : cela signifie que la fonction renvoie un pointeur sur un tableau de pointeurs
    • l'élément obtenu est déréférencé : la fonction renvoie dont un pointeur sur un tableau de pointeurs
    • finalement ce qu'on obtient est de type char
    Donc le type donné permet de déclarer p avec le type « fonction retournant un pointeur sur un tableau de pointeurs sur char ».


Le déréférencement

L'opérateur de déréférencement *

▶ Sans la possibilité d'accéder à l'information contenue dans l'espace dont l'adresse constitue la valeur d'un pointeur, le concept de pointeur serait inutile.

▶ Appliqué à un pointeur ptr de type T (il peut s'agir d'une constante, d'une variable ou d'une expression de pointeur), l'opérateur unaire * donne la valeur de la grandeur de type T qu'elle pointe. Autrement dit, la valeur de l'expression « *ptr » est la valeur de type T pointée par ptr.
Cette opération est appelée déréférencement.

Dans le cas où ptr est adressable (c'est-à-dire dans le cas où ptr est une variable), le déréférencement est l'opération inverse de la prise d'adresse (référencement) et les deux expressions « ptr » et expressions « *&ptr » sont alors équivalentes.

Le cas des pointeurs sur types scalaires

Les choses sont simples :

Le cas des pointeurs sur structures ou unions

▶ Etant donnée une variable p_st pointant sur une structure (ou une union), « *p_st » désigne le contenu en mémoire.

▶ Si la structure associée possède un champ de nom champ, l'accès à ce champ pour la zone pointée par p_st est réalisé avec l'expression « (*p_st).champ ». Les parenthèses sont nécessaires en raison des priorités respectives des opérateurs * et ..

▶ Afin de faciliter l'écriture de tels accès, un opérateur spécifique a été introduit .
Cet opérateur est noté ->.
L'expression « p_st->st » permet l'accès au champ champ de la structure pointée par p_st.

    --> cat ptr3.c
    #include <stdio.h>
    #include <stdlib.h>
    int main ( ) {
    #include <stdio.h>
        int i = 10;
        char c[6] = {'A', 'E', 'I', 'O', 'U', 'Y'};
        int *p_int = &i;
        printf("%d\n", *p_int);
        char *p_char = c; // équivalent à p_char = &c[0]
        printf("%c %c %c\n", *p_char, *p_char + 1, *(p_char + 1));
        return EXIT_SUCCESS; // return 0;
    }
    --> ./ptr3
    10
    A B E
    -->


Les constantes de pointeurs

Introduction

Sur une machine où où la taille d'un pointeur est n (c'est-à-dire 8n bits), tout nombre compris entre 0 et « e à la puissance 8n moins 1 » est codable dans l'espace alloué à un pointeur et est donc candidat au statut de pointeur.
Pour être acceptable syntaxiquement comme pointeur sur un type T, un tel nombre m doit être « transtypé » en un pointeur sur T, c'est-à-dire être écrit « (T *) m ».
Pour l'utiliser pour n'importe quel type de pointeur, il est possible d'utiliser « (void *) m ».
Remarque : ce n'est pas parce qu'une telle opération est syntaxiquement correcte, qu'elle sera sémantiquement correcte et que l'utilisation d'une telle adresse pour par exemple réaliser une écriture en mémoire ne produira pas une grave erreur lors de l'exécution, soit détectée (et matérialisée par un message de la forme « Memory fault.Core dumped »), soit ce qui est sans doute plus grave car non signalée, une écriture en un endroit indésirable de la mémoire, ce qui risque de conduire à des résultats erronés.

Les constantes « utiles »


▶ La constante symbolique NULL est prédéfinie dans le fichier d'interface stdio.h. Elle est associée à la valeur « (void *)0x0 » (pointeur nul).

▶ Etant donnée une variable x de type, T, &x est une constante du type « pointeur sur T ».

▶ Un tableau t défini de type T constitue une constante du type pointeur sur « T ». Cela signifie que l'identificateur t n'est pas acceptable en partie gauche d'une affectation.

▶ Un identificateur de fonction définie est un pointeur constant sur fonction.
Comme un identificateur de tableau, un identificateur de fonction n'est donc pas acceptable en partie gauche d'une affectation.

▶ Nous verrons qu'à partir de ces constantes, on peut construire des expressions de pointeurs et par là de nouvelles constantes de pointeurs.


Opérations sur les pointeurs, expressions de pointeurs

Initialisation, affectation d'un pointeur

▶ Tout d'abord

  • toute variable de type pointeur de la classe d'allocation statique (et donc définie à l'extérieur de toute fonction ou qualifiée static) est initialisée à la valeur null, à moins d'une demande explicite dans la définition;
  • les variables de type pointeur définies localement (non qualifiées static ou non initialisées explicitement) ne font l'objet d'aucune initialisation : le contenu de l'espace qui leur est alloué au moment où cette allocation est réalisée en constitue la valeur (la suite de 0 et de 1 est interprétée comme une adresse).

▶ L'affectation d'une valeur à un pointeur est réalisée par l'opérateur =.

Le membre droit droit d'unne telle affectation peut être :

Arithmétique des pointeurs

▶ Il est possible de réaliser sur les pointeurs les opérations suivantes :

  • pré et post-incrémentation (++) et pré et post-d\écrémentation (--)
  • addition d'un entier(+) ou soustraction d'un entier (-)
  • affectations étendues {+= et -=)
  • différence de deux pointeurs de même type complet envoyant un nombre entier
  • comparaison de deux pointeurs de même type (==, !=, <, etc.).

▶ Dans ces opérations, la taille de l'objet pointé joue un rôle prépondérant. La valeur des entiers qui sont manipulés est pondérée par la taille du type sur lequel le pointeur pointe.

Ainsi :

  • ajouter 1 à un pointeur sur « char » dont la valeur vue comme un entier est n donnera à ce pointeur la valeur vue comme un entier de n+1
  • ajouter 1 à un pointeur sur « short » dont la valeur vue comme un entier est n donnera à ce pointeur la valeur vue comme un entier de n+2 (puisque la taille d'un short est 2)
  • de manière générale >ajouter 1 à un pointeur sur « T » dont la valeur vue comme un entier est n donnera à ce pointeur la valeur vue comme un entier de « n+sizeof(T) »

▶ L'objectif visé dans ces opérations est de fournir un outil de parcours rapide des tableaux : si t est un identificateur de tableau dont les éléments sont de type T et n est un entier, l'expression « t+n » constitue de fait une constante de pointeur sur l'élément t[n] et n'est en fait rien d'autre que &t[n].


Les pointeurs sur fonction

Introduction

Ils permettent

  • de transmettre des fonctions en paramètres;
  • de constituer des tableaux de fonctions;
  • d'écrire des fonctions retournant des fonctions comme valeur.

Les types pointeurs sur fonction

La définition d'une fonction définit de facto un pointeur sur fonction et de manière générale, la définition d'une fonction fonc de prototype

    T fonc(T1,... ,Tn);

définit un pointeur constant (donc non modifiable) de type incomplet (et donc utilisable uniquement comme paramètre dans un prototype sans nommage du paramètre :

    T (*)(T1,... ,Tn);

La définition d'un pointeur ptr_fonc vers une fonction telle que la fonction fonc précédente est réalisée par :

    T (*ptr_fonc)(T1,... ,Tn);

▶ On en déduit la définition d'un nom de type pour ces pointeurs 

    typedef T (*pointeur_fonction)(T1,... ,Tn);
qui autorise une définition telle que
    pointeur_fonction ptr_fonc;

Exemple récapitulatif

Cet exemple illustre chacune des déclarations/définitions précédentes :

    --> cat ptrFonc.c
    #include <stdio.h>
    #include <stdlib.h>

    /* définition du type pointeur sur fonction ayant
       2 paramètres de type int et renvoyant un int */
    typedef int (*pointeur_fonction)(int, int);

    int somme(int a, int b){ return a + b;}
    int produit(int a, int b){ return a * b;}

    /* déclaration d'une fonction renvoyant un entier
       et ayant en paramètres un pointeur sur fonction
       renvoyant un int et ayant 2 int en paramètres */
    int calculer(int (*)(int, int), int, int);

    /* définition d'une fonction ayant en paramètre un
       caractère et renvoyant un pointeur sur fonction
       renvoyant un int et ayant 2 int en paramètres */

    int (*quelle_fonction(char c))(int, int) {
        if (c == '+') return somme;
        else return produit;
    }

    pointeur_fonction tab_fonc[2] = {somme, produit};

    int main() {
        pointeur_fonction ptr_f;
        int (*ptr_fonc)(int, int);
        ptr_fonc = somme;
        printf("%d\n", (*ptr_fonc)(4,5));
        ptr_f = produit;
        printf("%d\n", (*ptr_f)(4,5));
        printf("%d\n", calculer(somme, 6, 7));
        printf("%d\n", calculer(produit, 6, 7));
        printf("%d\n", (*tab_fonc[0])(100,45));
        printf("%d\n", ((*quelle_fonction)('*'))(32,6));
        return EXIT_SUCCESS; // return 0;
    }

    /* définition de la fonction calculer déclarée plus haut */
    int calculer(int (*f)(int, int), int a, int b){
        return (*f)(a,b);
    }
    --> ./ptrFonc
    9
           <--- on a calculé somme(4,5)
    20      <--- on a calculé produit(4,5)
    13      <--- on a calculé somme(6,7)
    42      <--- on a calculé produit(6,7)
    145     <--- on a calculé somme(100,45)
    192     <--- on a calculé produit(32,6)
    -->