IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Cours de C/C++


précédentsommairesuivant

4. Les pointeurs et références

Les pointeurs sont des variables très utilisées en C et en C++. Ils doivent être considérés comme des variables, il n'y a rien de sorcier derrière les pointeurs. Cependant, les pointeurs ont un domaine d'application très vaste.

Les références sont des identificateurs synonymes d'autres identificateurs, qui permettent de manipuler certaines notions introduites avec les pointeurs plus souplement. Elles n'existent qu'en C++.

4.1. Notion d'adresse

Tout objet manipulé par l'ordinateur est stocké dans sa mémoire. On peut considérer que cette mémoire est constituée d'une série de « cases », cases dans lesquelles sont stockées les valeurs des variables ou les instructions du programme. Pour pouvoir accéder à un objet (la valeur d'une variable ou les instructions à exécuter par exemple), c'est-à-dire au contenu de la case mémoire dans laquelle cet objet est enregistré, il faut connaître le numéro de cette case. Autrement dit, il faut connaître l'emplacement en mémoire de l'objet à manipuler. Cet emplacement est appelé l'adresse de la case mémoire, et par extension, l'adresse de la variable ou l'adresse de la fonction stockée dans cette case et celles qui la suivent.

Toute case mémoire a une adresse unique. Lorsqu'on utilise une variable ou une fonction, le compilateur manipule l'adresse de cette dernière pour y accéder. C'est lui qui connaît cette adresse, le programmeur n'a pas à s'en soucier.

4.2. Notion de pointeur

Une adresse est une valeur. On peut donc stocker cette valeur dans une variable. Les pointeurs sont justement des variables qui contiennent l'adresse d'autres objets, par exemple l'adresse d'une autre variable. On dit que le pointeur pointe sur la variable pointée. Ici, pointer signifie « faire référence à ». Les adresses sont généralement des valeurs constantes, car en général un objet ne se déplace pas en mémoire. Toutefois, la valeur d'un pointeur peut changer. Cela ne signifie pas que la variable pointée est déplacée en mémoire, mais plutôt que le pointeur pointe sur autre chose.

Afin de savoir ce qui est pointé par un pointeur, les pointeurs disposent d'un type. Ce type est construit à partir du type de l'objet pointé. Cela permet au compilateur de vérifier que les manipulations réalisées en mémoire par l'intermédiaire du pointeur sont valides. Le type des pointeur se lit « pointeur de ... », où les points de suspension représentent le nom du type de l'objet pointé.

Les pointeurs se déclarent en donnant le type de l'objet qu'ils devront pointer, suivi de leur identificateur précédé d'une étoile :

 
Sélectionnez
int *pi;   // pi est un pointeur d'entier.

Note : Si plusieurs pointeurs doivent être déclarés, l'étoile doit être répétée :

 
Sélectionnez
int *pi1, *pi2, j, *pi3;

Ici, pi1, pi2 et pi3 sont des pointeurs d'entiers et j est un entier.

Figure 4-1. Notion de pointeur et d'adresse
Figure 4-1. Notion de pointeur et d'adresse

Il est possible de faire un pointeur sur une structure dans une structure en indiquant le nom de la structure comme type du pointeur :

 
Sélectionnez
typedef struct nom
{
    struct nom *pointeur;   /* Pointeur sur une structure "nom". */
    ...
} MaStructure;

Ce type de construction permet de créer des listes de structures, dans lesquelles chaque structure contient l'adresse de la structure suivante dans la liste. Nous verrons plus loin un exemple d'utilisation de ce genre de structure.

Il est également possible de créer des pointeurs sur des fonctions, et d'utiliser ces pointeurs pour paramétrer un algorithme, dont le comportement dépendra des fonctions ainsi pointées. Nous détaillerons plus loin ce type d'utilisation des pointeurs.

4.3. Déréférencement, indirection

Un pointeur ne servirait strictement à rien s'il n'y avait pas de possibilité d'accéder à l'adresse d'une variable ou d'une fonction (on ne pourrait alors pas l'initialiser) ou s'il n'y avait pas moyen d'accéder à l'objet référencé par le pointeur (la variable pointée ne pourrait pas être manipulée ou la fonction pointée ne pourrait pas être appelée).

Ces deux opérations sont respectivement appelées indirection et déréférencement. Il existe deux opérateurs permettant de récupérer l'adresse d'un objet et d'accéder à l'objet pointé. Ces opérateurs sont respectivement & et *.

Il est très important de s'assurer que les pointeurs que l'on manipule sont tous initialisés (c'est-à-dire contiennent l'adresse d'un objet valide, et pas n'importe quoi). En effet, accéder à un pointeur non initialisé revient à lire ou, plus grave encore, à écrire dans la mémoire à un endroit complètement aléatoire (selon la valeur initiale du pointeur lors de sa création). En général, on initialise les pointeurs dès leur création, ou, s'ils doivent être utilisés ultérieurement, on les initialise avec le pointeur nul. Cela permettra de faire ultérieurement des tests sur la validité du pointeur ou au moins de détecter les erreurs. En effet, l'utilisation d'un pointeur initialisé avec le pointeur nul génère souvent une faute de protection du programme, que tout bon débogueur est capable de détecter. Le pointeur nul se note NULL.

Note : NULL est une macro définie dans le fichier d'en-tête stdlib.h. En C, elle représente la valeur d'une adresse invalide. Malheureusement, cette valeur peut ne pas être égale à l'adresse 0 (certains compilateurs utilisent la valeur -1 pour NULL par exemple). C'est pour cela que cette macro a été définie, afin de représenter, selon le compilateur, la bonne valeur. Voir le Chapitre 5 pour plus de détails sur les macros et sur les fichiers d'en-tête.

La norme du C++ fixe la valeur nulle des pointeurs à 0. Par conséquent, les compilateurs C/C++ qui définissent NULL comme étant égal à -1 posent un problème de portabilité certain, puisque un programme C qui utilise NULL n'est plus valide en C++. Par ailleurs, un morceau de programme C++ compilable en C qui utiliserait la valeur 0 ne serait pas correct en C.

Il faut donc faire un choix : soit utiliser NULL en C et 0 en C++, soit utiliser NULL partout, quitte à redéfinir la macro NULL pour les programmes C++ (solution qui me semble plus pratique).

Exemple 4-1. Déclaration de pointeurs
Sélectionnez
int i=0;      /* Déclare une variable entière. */
int *pi;      /* Déclare un pointeur sur un entier. */
pi=&i;        /* Initialise le pointeur avec l'adresse de cette
                variable. */
*pi = *pi+1;  /* Effectue un calcul sur la variable pointée par pi,
                 c'est-à-dire sur i lui-même, puisque pi contient
                 l'adresse de i. */
 
              /* À ce stade, i ne vaut plus 0, mais 1. */

Il est à présent facile de comprendre pourquoi il faut répéter l'étoile dans la déclaration de plusieurs pointeurs :

 
Sélectionnez
int *p1, *p2, *p3;

signifie syntaxiquement : p1, p2 et p3 sont des pointeurs d'entiers, mais aussi *p1, *p2 et *p3 sont des entiers.

Si l'on avait écrit :

 
Sélectionnez
int *p1, p2, p3;

seul p1 serait un pointeur d'entier. p2 et p3 seraient des entiers.

L'accès aux champs d'une structure par le pointeur sur cette structure se fera avec l'opérateur '->', qui remplace '(*).'.

Exemple 4-2. Utilisation de pointeurs de structures
Sélectionnez
struct Client
{
    int Age;
};
 
Client structure1;
Client *pstr = &structure1;
pstr->Age = 35;   /* On aurait pu écrire (*pstr).Age=35; */

4.4. Notion de référence

En plus des pointeurs, le C++ permet de créer des références. Les références sont des synonymes d'identificateurs. Elles permettent de manipuler une variable sous un autre nom que celui sous laquelle cette dernière a été déclarée.

Note : Les références n'existent qu'en C++. Le C ne permet pas de créer des références.

Par exemple, si « id » est le nom d'une variable, il est possible de créer une référence « ref » de cette variable. Les deux identificateurs id et ref représentent alors la même variable, et celle-ci peut être accédée et modifiée à l'aide de ces deux identificateurs indistinctement.

Toute référence doit se référer à un identificateur : il est donc impossible de déclarer une référence sans l'initialiser. De plus, la déclaration d'une référence ne crée pas un nouvel objet comme c'est le cas pour la déclaration d'une variable par exemple. En effet, les références se rapportent à des identificateurs déjà existants.

La syntaxe de la déclaration d'une référence est la suivante :

 
Sélectionnez
type &référence = identificateur;

Après cette déclaration, référence peut être utilisé partout où identificateur peut l'être. Ce sont des synonymes.

Exemple 4-3. Déclaration de références
Sélectionnez
int i=0;
int &ri=i;   // Référence sur la variable i.
ri=ri+i;         // Double la valeur de i (et de ri).

Il est possible de faire des références sur des valeurs numériques. Dans ce cas, les références doivent être déclarées comme étant constantes, puisqu'une valeur est une constante :

 
Sélectionnez
const int &ri=3;  // Référence sur 3.
int &error=4;     // Erreur ! La référence n'est pas constante.

4.5. Lien entre les pointeurs et les références

Les références et les pointeurs sont étroitement liés. En effet, une variable et ses différentes références ont la même adresse, puisqu'elles permettent d'accéder à un même objet. Utiliser une référence pour manipuler un objet revient donc exactement au même que de manipuler un pointeur constant contenant l'adresse de cet objet. Les références permettent simplement d'obtenir le même résultat que les pointeurs, mais avec une plus grande facilité d'écriture.

Cette similitude entre les pointeurs et les références se retrouve au niveau syntaxique. Par exemple, considérons le morceau de code suivant :

 
Sélectionnez
int i=0;
int *pi=&i;
*pi=*pi+1;   // Manipulation de i via pi.

et faisons passer l'opérateur & de la deuxième ligne à gauche de l'opérateur d'affectation :

 
Sélectionnez
int i=0;
int &*pi=i;   // Cela génère une erreur de syntaxe mais nous
              // l'ignorons pour les besoins de l'explication.
*pi=*pi+1;

Maintenant, comparons avec le morceau de code équivalent suivant :

 
Sélectionnez
int i=0;
int &ri=i;
ri=ri+1;      // Manipulation de i via ri.

Nous constatons que la référence ri peut être identifiée avec l'expression *pi, qui représente bel et bien la variable i. Ainsi, la référence ri encapsule la manipulation de l'adresse de la variable i et s'utilise comme l'expression *pi. Cela permet de comprendre l'origine de la syntaxe de déclaration des références. La différence se trouve ici dans le fait que les références doivent être initialisées d'une part, et que l'on n'a pas à effectuer le déréférencement d'autre part. Les références sont donc beaucoup plus faciles à manipuler que les pointeurs, et permettent de faire du code beaucoup plus sûr.

4.6. Passage de paramètres par variable ou par valeur

Il y a deux méthodes pour passer des variables en paramètre dans une fonction : le passage par valeur et le passage par variable. Ces méthodes sont décrites ci-dessous.

4.6.1. Passage par valeur

La valeur de l'expression passée en paramètre est copiée dans une variable locale. C'est cette variable qui est utilisée pour faire les calculs dans la fonction appelée.

Si l'expression passée en paramètre est une variable, son contenu est copié dans la variable locale. Aucune modification de la variable locale dans la fonction appelée ne modifie la variable passée en paramètre, parce que ces modifications ne s'appliquent qu'à une copie de cette dernière.

Le C ne permet de faire que des passages par valeur.

Exemple 4-4. Passage de paramètre par valeur
Sélectionnez
void test(int j)    /* j est la copie de la valeur passée en
                       paramètre */
{
    j=3;            /* Modifie j, mais pas la variable fournie
                       par l'appelant. */
    return;
}
 
int main(void)
{
    int i=2;
    test(i);        /* Le contenu de i est copié dans j.
                       i n'est pas modifié. Il vaut toujours 2. */
    test(2);        /* La valeur 2 est copiée dans j. */
    return 0;
}

4.6.2. Passage par variable

La deuxième technique consiste à passer non plus la valeur des variables comme paramètre, mais à passer les variables elles-mêmes. Il n'y a donc plus de copie, plus de variable locale. Toute modification du paramètre dans la fonction appelée entraîne la modification de la variable passée en paramètre.

Le C ne permet pas de faire ce type de passage de paramètres (le C++ le permet en revanche).

Exemple 4-5. Passage de paramètre par variable en Pascal
Sélectionnez
Var i : integer;
 
Procedure test(Var j : integer)
Begin
            {La variable j est strictement égale
             à la variable passée en paramètre.}
   j:=2;    {Ici, cette variable est modifiée.}
End;
 
Begin
   i:=3;    {Initialise i à 3}
   test(i); {Appelle la fonction. La variable i est passée en
             paramètres, pas sa valeur. Elle est modifiée par
             la fonction test.}
 
     {Ici, i vaut 2.}
End.

Puisque la fonction attend une variable en paramètre, on ne peut plus appeler test avec une valeur (test(3) est maintenant interdit, car 3 n'est pas une variable : on ne peut pas le modifier).

4.6.3. Avantages et inconvénients des deux méthodes

Les passages par variables sont plus rapides et plus économes en mémoire que les passages par valeur, puisque les étapes de la création de la variable locale et la copie de la valeur ne sont pas faites. Il faut donc éviter les passages par valeur dans les cas d'appels récursifs de fonction ou de fonctions travaillant avec des grandes structures de données (matrices par exemple).

Les passages par valeurs permettent d'éviter de détruire par mégarde les variables passées en paramètre. Si l'on veut se prévenir de la destruction accidentelle des paramètres passés par variable, il faut utiliser le mot clé const. Le compilateur interdira alors toute modification de la variable dans la fonction appelée, ce qui peut parfois obliger cette fonction à réaliser des copies de travail en local.

4.6.4. Comment passer les paramètres par variable en C ?

Il n'y a qu'une solution : passer l'adresse de la variable. Cela constitue donc une application des pointeurs.

Voici comment l'Exemple 4-5 serait programmé en C :

Exemple 4-6. Passage de paramètre par variable en C
Sélectionnez
void test(int *pj)  /* test attend l'adresse d'un entier... */
{
    *pj=2;          /* ... pour le modifier. */
    return;
}
 
int main(void)
{
    int i=3;
    test(&i);       /* On passe l'adresse de i en paramètre. */
    /* Ici, i vaut 2. */
    return 0;
}

À présent, il est facile de comprendre la signification de & dans l'appel de scanf : les variables à entrer sont passées par variable.

4.6.5. Passage de paramètres par référence

La solution du C est exactement la même que celle du Pascal du point de vue sémantique. En fait, le Pascal procède exactement de la même manière en interne, mais la manipulation des pointeurs est masquée par le langage. Cependant, plusieurs problèmes se posent au niveau syntaxique :

  • la syntaxe est lourde dans la fonction, à cause de l'emploi de l'opérateur * devant les paramètres ;
  • la syntaxe est dangereuse lors de l'appel de la fonction, puisqu'il faut systématiquement penser à utiliser l'opérateur & devant les paramètres. Un oubli devant une variable de type entier et la valeur de l'entier est utilisée à la place de son adresse dans la fonction appelée (plantage assuré, essayez avec scanf).

Le C++ permet de résoudre tous ces problèmes à l'aide des références. Au lieu de passer les adresses des variables, il suffit de passer les variables elles-mêmes en utilisant des paramètres sous la forme de références. La syntaxe des paramètres devient alors :

 
Sélectionnez
type &identificateur [, type &identificateur [...]]
Exemple 4-7. Passage de paramètre par référence en C++
Sélectionnez
void test(int &i)   // i est une référence du paramètre constant.
{
    i = 2;    // Modifie le paramètre passé en référence.
    return;
}
 
int main(void)
{
    int i=3;
    test(i);
    // Après l'appel de test, i vaut 2.
    // L'opérateur & n'est pas nécessaire pour appeler
    // test.
    return 0;
}

Il est recommandé, pour des raisons de performances, de passer par référence tous les paramètres dont la copie peut prendre beaucoup de temps (en pratique, seuls les types de base du langage pourront être passés par valeur). Bien entendu, il faut utiliser des références constantes au maximum afin d'éviter les modifications accidentelles des variables de la fonction appelante dans la fonction appelée. En revanche, les paramètres de retour des fonctions ne devront pas être déclarés comme des références constantes, car on ne pourrait pas les écrire si c'était le cas.

Exemple 4-8. Passage de paramètres constant par référence
Sélectionnez
typedef struct
{
    ...
} structure;
 
void ma_fonction(const structure & s)
{
    ...
    return ;
}

Dans cet exemple, s est une référence sur une structure constante. Le code se trouvant à l'intérieur de la fonction ne peut donc pas utiliser la référence s pour modifier la structure (on notera cependant que c'est la fonction elle-même qui s'interdit l'écriture dans la variable s. const est donc un mot clé « coopératif ». Il n'est pas possible à un programmeur d'empêcher ses collègues d'écrire dans ses variables avec le mot clé const. Nous verrons dans le Chapitre 8 que le C++ permet de pallier ce problème grâce à une technique appelée l'encapsulation.).

Un autre avantage des références constantes pour les passages par variables est que si le paramètre n'est pas une variable ou, s'il n'est pas du bon type, une variable locale du type du paramètre est créée et initialisée avec la valeur du paramètre transtypé.

Exemple 4-9. Création d'un objet temporaire lors d'un passage par référence
Sélectionnez
void test(const int &i)
{
    ...       // Utilisation de la variable i
              // dans la fonction test. La variable
              // i est créée si nécessaire.
    return ;
}
 
int main(void)
{
    test(3);   // Appel de test avec une constante.
    return 0;
}

Au cours de cet appel, une variable locale est créée (la variable i de la fonction test), et 3 lui est affectée.

4.7. Références et pointeurs constants et volatiles

L'utilisation des mots clés const et volatile avec les pointeurs et les références est un peu plus compliquée qu'avec les types simples. En effet, il est possible de déclarer des pointeurs sur des variables, des pointeurs constants sur des variables, des pointeurs sur des variables constantes et des pointeurs constants sur des variables constantes (bien entendu, il en est de même avec les références). La position des mots clés const et volatile dans les déclarations des types complexes est donc extrêmement importante. En général, les mots clés const et volatile caractérisent ce qui les précède dans la déclaration, si l'on adopte comme règle de toujours les placer après les types de base. Par exemple, l'expression suivante :

 
Sélectionnez
const int *pi;

peut être réécrite de la manière suivante :

 
Sélectionnez
int const *pi;

puisque le mot clé const est interchangeable avec le type le plus simple dans une déclaration. Ce mot clé caractérise donc le type int, et pi est un pointeur sur un entier constant. En revanche, dans l'exemple suivant :

 
Sélectionnez
int j;
int * const pi=&j;

pi est déclaré comme étant constant, et de type pointeur d'entier. Il s'agit donc d'un pointeur constant sur un entier non constant, que l'on initialise pour référencer la variable j.

Note : Les déclarations C++ peuvent devenir très compliquées et difficiles à lire. Il existe une astuce qui permet de les interpréter facilement. Lors de l'analyse de la déclaration d'un identificateur X, il faut toujours commencer par une phrase du type « X est un ... ». Pour trouver la suite de la phrase, il suffit de lire la déclaration en partant de l'identificateur et de suivre l'ordre imposé par les priorités des opérateurs. Cet ordre peut être modifié par la présence de parenthèses. L'annexe B donne les priorités de tous les opérateurs du C++.

Ainsi, dans l'exemple suivant :

 
Sélectionnez
const int *pi[12];
void (*pf)(int * const pi);

la première déclaration se lit de la manière suivante : « pi (pi) est un tableau ([]) de 12 (12) pointeurs (*) d'entiers (int) constants (const) ». La deuxième déclaration se lit : « pf (pf) est un pointeur (*) de fonction (()) de pi (pi), qui est lui-même une constante (const) de type pointeur (*) d'entier (int). Cette fonction ne renvoie rien (void) ».

Le C et le C++ n'autorisent que les écritures qui conservent ou augmentent les propriétés de constance et de volatilité. Par exemple, le code suivant est correct :

 
Sélectionnez
char *pc;
const char *cpc;
 
cpc=pc;   /* Le passage de pc à cpc augmente la constance. */

parce qu'elle signifie que si l'on peut écrire dans une variable par l'intermédiaire du pointeur pc, on peut s'interdire de le faire en utilisant cpc à la place de pc. En revanche, si l'on n'a pas le droit d'écrire dans une variable, on ne peut en aucun cas se le donner.

Cependant, les règles du langage relatives à la modification des variables peuvent parfois paraître étranges. Par exemple, le langage interdit une écriture telle que celle-ci :

 
Sélectionnez
char *pc;
const char **ppc;
 
ppc = &pc;   /* Interdit ! */

Pourtant, cet exemple ressemble beaucoup à l'exemple précédent. On pourrait penser que le fait d'affecter un pointeur de pointeur de variable à un pointeur de pointeur de variable constante revient à s'interdire d'écrire dans une variable qu'on a le droit de modifier. Mais en réalité, cette écriture va contre les règles de constances, parce qu'elle permettrait de modifier une variable constante. Pour s'en convaincre, il faut regarder l'exemple suivant :

 
Sélectionnez
const char c='a';      /* La variable constante. */
char *pc;              /* Pointeur par l'intermédiaire duquel
                          nous allons modifier c. */
const char **ppc=&pc;  /* Interdit, mais supposons que ce ne le
                          soit pas. */
*ppc=&c;               /* Parfaitement légal. */
*pc='b';               /* Modifie la variable c. */

Que s'est-il passé ? Nous avons, par l'intermédiaire de ppc, affecté l'adresse de la constante c au pointeur pc. Malheureusement, pc n'est pas un pointeur de constante, et cela nous a permis de modifier la constante c.

Afin de gérer correctement cette situation (et les situations plus complexes qui utilisent des triples pointeurs ou encore plus d'indirection), le C et le C++ interdisent l'affectation de tout pointeur dont les propriétés de constance et de volatilité sont moindres que celles du pointeur cible. La règle exacte est la suivante :

  1. On note cv les différentes qualifications de constance et de volatilité possibles (à savoir : const volatile, const, volatile ou aucune classe de stockage).
  2. Si le pointeur source est un pointeur cvs,0 de pointeur cvs,1 de pointeur ... de pointeur cvs,n-1 de type Ts cvs,n, et que le pointeur destination est un pointeur cvd,0 de pointeur cvd,1 de pointeur ... de pointeur cvd,n-1 de type Td cvs,n, alors l'affectation de la source à la destination n'est légale que si :

    1. les types source Ts et destination Td sont compatibles ;
    2. il existe un nombre entier strictement positif N tel que, quel que soit j supérieur ou égal à N, on ait :

      1. si const apparaît dans cvs,j, alors const apparaît dans cvd,j ;
      2. si volatile apparaît dans cvs,j, alors volatile apparaît dans cvd,j ;
      3. et tel que, quel que soit
        const apparaisse dans cvd,k.

Ces règles sont suffisamment compliquées pour ne pas être apprises. Les compilateurs se chargeront de signaler les erreurs s'il y en a en pratique. Par exemple :

 
Sélectionnez
const char c='a';
const char *pc;
const char **ppc=&pc;  /* Légal à présent. */
*ppc=&c;
*pc='b';               /* Illégal (pc a changé de type). */

L'affectation de double pointeur est à présent légale, parce que le pointeur source a changé de type (on ne peut cependant toujours pas modifier le caractère c).

Il existe une exception notable à ces règles : l'initialisation des chaînes de caractères. Les chaînes de caractères telles que :

 
Sélectionnez
"Bonjour tout le monde !"

sont des chaînes de caractères constantes. Par conséquent, on ne peut théoriquement affecter leur adresse qu'à des pointeurs de caractères constants :

 
Sélectionnez
const char *pc="Coucou !"; /* Code correct. */

Cependant, il a toujours été d'usage de réaliser l'initialisation des chaînes de caractères de la manière suivante :

 
Sélectionnez
char *pc="Coucou !";       /* Théoriquement illégal, mais toléré
                              par compatibilité avec le C. */

Par compatibilité, le langage fournit donc une conversion implicite entre « const char * » et « char * ». Cette facilité ne doit pas pour autant vous inciter à transgresser les règles de constance : utilisez les pointeurs sur les chaînes de caractères constants autant que vous le pourrez (quitte à réaliser quelques copies de chaînes lorsqu'un pointeur de caractère simple doit être utilisé). Sur certains systèmes, l'écriture dans une chaîne de caractères constante peut provoquer un plantage immédiat du programme.

4.8. Arithmétique des pointeurs

Il est possible d'effectuer des opérations arithmétiques sur les pointeurs.

Les seules opérations valides sont les opérations externes (addition et soustraction des entiers) et la soustraction de pointeurs. Elles sont définies comme suit (la soustraction d'un entier est considérée comme l'addition d'un entier négatif) :

 
Sélectionnez
p + i = adresse contenue dans p + i*taille(élément pointé par p)
et : 
p2 - p1 = (adresse contenue dans p2 - adresse contenue dans p1) /
          taille(éléments pointés par p1 et p2)

Si p est un pointeur d'entier, p+1 est donc le pointeur sur l'entier qui suit immédiatement celui pointé par p. On retiendra surtout que l'entier qu'on additionne au pointeur est multiplié par la taille de l'élément pointé pour obtenir la nouvelle adresse.

Le type du résultat de la soustraction de deux pointeurs est très dépendant de la machine cible et du modèle mémoire du programme. En général, on ne pourra jamais supposer que la soustraction de deux pointeurs est un entier (que les chevronnés du C me pardonnent, mais c'est une erreur très grave). En effet, ce type peut être insuffisant pour stocker des adresses (une machine peut avoir des adresses sur 64 bits et des données sur 32 bits). Pour résoudre ce problème, le fichier d'en-tête stdlib.h contient la définition du type à utiliser pour la différence de deux pointeurs. Ce type est nommé ptrdiff_t.

Exemple 4-10. Arithmétique des pointeurs
Sélectionnez
int i, j;
ptrdiff_t delta = &i - &j;  /* Correct */
int error = &i - &j;        /* Peut marcher, mais par chance. */

Il est possible de connaître la taille d'un élément en caractères en utilisant l'opérateur sizeof. Il a la syntaxe d'une fonction :

 
Sélectionnez
sizeof(type|expression)

Il attend soit un type, soit une expression. La valeur retournée est soit la taille du type en caractères, soit celle du type de l'expression. Dans le cas des tableaux, il renvoie la taille totale du tableau. Si son argument est une expression, celle-ci n'est pas évaluée (donc si il contient un appel à une fonction, celle-ci n'est pas appelée). Par exemple :

 
Sélectionnez
sizeof(int)

renvoie la taille d'un entier en caractères, et :

 
Sélectionnez
sizeof(2+3)

renvoie la même taille, car 2+3 est de type entier. 2+3 n'est pas calculé.

Note : L'opérateur sizeof renvoie la taille des types en tenant compte de leur alignement. Cela signifie par exemple que même si un compilateur espace les éléments d'un tableau afin de les aligner sur des mots mémoire de la machine, la taille des éléments du tableau sera celle des objets de même type qui ne se trouvent pas dans ce tableau (ils devront donc être alignés eux aussi). On a donc toujours l'égalité suivante :

 
Sélectionnez
sizeof(tableau) = sizeof(élément) * nombre d'éléments

4.9. Utilisation des pointeurs avec les tableaux

Les tableaux sont étroitement liés aux pointeurs parce que, de manière interne, l'accès aux éléments des tableaux se fait par manipulation de leur adresse de base, de la taille des éléments et de leurs indices. En fait, l'adresse du n-ième élément d'un tableau est calculée avec la formule :

 
Sélectionnez
Adresse_n = Adresse_Base + n*taille(élément)

taille(élément) représente la taille de chaque élément du tableau et Adresse_Base l'adresse de base du tableau. Cette adresse de base est l'adresse du début du tableau, c'est donc à la fois l'adresse du tableau et l'adresse de son premier élément.

Ce lien apparaît au niveau du langage dans les conversions implicites de tableaux en pointeurs, et dans le passage des tableaux en paramètre des fonctions.

4.9.1. Conversions des tableaux en pointeurs

Afin de pouvoir utiliser l'arithmétique des pointeurs pour manipuler les éléments des tableaux, le C++ effectue les conversions implicites suivantes lorsque nécessaire :

  • tableau vers pointeur d'élément ;
  • pointeur d'élément vers tableau.

Cela permet de considérer les expressions suivantes comme équivalentes :

 
Sélectionnez
identificateur[n]

et :

 
Sélectionnez
*(identificateur + n)

si identificateur est soit un identificateur de tableau, soit celui d'un pointeur.

Exemple 4-11. Accès aux éléments d'un tableau par pointeurs
Sélectionnez
int tableau[100];
int *pi=tableau;
 
tableau[3]=5;   /* Le 4ème élément est initialisé à 5 */
*(tableau+2)=4; /* Le 3ème élément est initialisé à 4 */
pi[5]=1;        /* Le 6ème élément est initialisé à 1 */

Note : Le langage C++ impose que l'adresse suivant le dernier élément d'un tableau doit toujours être valide. Cela ne signifie absolument pas que la zone mémoire référencée par cette adresse est valide, bien au contraire, mais plutôt que cette adresse est valide. Il est donc garantit que cette adresse ne sera pas le pointeur NULL par exemple, ni toute autre valeur spéciale qu'un pointeur ne peut pas stocker. Il sera donc possible de faire des calculs d'arithmétique des pointeurs avec cette adresse, même si elle ne devra jamais être déréférencée, sous peine de voir le programme planter.

On prendra garde à certaines subtilités. Les conversions implicites sont une facilité introduite par le compilateur, mais en réalité, les tableaux ne sont pas des pointeurs, ce sont des variables comme les autres, à ceci près : leur type est convertible en pointeur sur le type de leurs éléments. Il en résulte parfois quelques ambiguïtés lorsqu'on manipule les adresses des tableaux. En particulier, on a l'égalité suivante :

 
Sélectionnez
&tableau == tableau

en raison du fait que l'adresse du tableau est la même que celle de son premier élément. Il faut bien comprendre que dans cette expression, une conversion a lieu. Cette égalité n'est donc pas exacte en théorie. En effet, si c'était le cas, on pourrait écrire :

 
Sélectionnez
*&tableau == tableau

puisque les opérateurs * et & sont conjugués, d'où :

 
Sélectionnez
tableau == *&tableau = *(&tableau) == *(tableau) == t[0]

ce qui est faux (le type du premier élément n'est en général pas convertible en type pointeur.).

4.9.2. Paramètres de fonction de type tableau

La conséquence la plus importante de la conversion tableau vers pointeur se trouve dans le passage par variable des tableaux dans une fonction. Lors du passage d'un tableau en paramètre d'une fonction, la conversion implicite a lieu, les tableaux sont donc toujours passés par variable, jamais par valeur. Il est donc faux d'utiliser des pointeurs pour les passer en paramètre, car le paramètre aurait le type pointeur de tableau. On ne modifierait pas le tableau, mais bel et bien le pointeur du tableau. Le programme aurait donc de fortes chances de planter.

Par ailleurs, certaines caractéristiques des tableaux peuvent être utilisées pour les passer en paramètre dans les fonctions.

Il est autorisé de ne pas spécifier la taille de la dernière dimension des paramètres de type tableau dans les déclarations et les définitions de fonctions. En effet, la borne supérieure des tableaux n'a pas besoin d'être précisée pour manipuler leurs éléments (on peut malgré tout la donner si cela semble nécessaire).

Cependant, pour les dimensions deux et suivantes, les tailles des premières dimensions restent nécessaires. Si elles n'étaient pas données explicitement, le compilateur ne pourrait pas connaître le rapport des dimensions. Par exemple, la syntaxe :

 
Sélectionnez
int tableau[][];

utilisée pour référencer un tableau de 12 entiers ne permettrait pas de faire la différence entre les tableaux de deux lignes et de six colonnes et les tableaux de trois lignes et de quatre colonnes (et leurs transposés respectifs). Une référence telle que :

 
Sélectionnez
tableau[1][3]

ne représenterait rien. Selon le type de tableau, l'élément référencé serait le quatrième élément de la deuxième ligne (de six éléments), soit le dixième élément, ou bien le quatrième élément de la deuxième ligne (de quatre éléments), soit le huitième élément du tableau. En précisant tous les indices sauf un, il est possible de connaître la taille du tableau pour cet indice à partir de la taille globale du tableau, en la divisant par les tailles sur les autres dimensions (2 = 12/6 ou 3 = 12/4 par exemple).

Le programme d'exemple suivant illustre le passage des tableaux en paramètre :

Exemple 4-12. Passage de tableau en paramètre
Sélectionnez
int tab[10][20];
 
void test(int t[][20])
{
    /* Utilisation de t[i][j] ... */
    return;
}
 
int main(void)
{
    test(tab);   /* Passage du tableau en paramètre. */
    return 0;
}

4.10. Les chaînes de caractères : pointeurs et tableaux à la fois !

On a vu dans le premier chapitre que les chaînes de caractères n'existaient pas en C/C++. Ce sont en réalité des tableaux de caractères dont le dernier caractère est le caractère nul.

Cela a plusieurs conséquences. La première, c'est que les chaînes de caractères sont aussi des pointeurs sur des caractères, ce qui se traduit dans la syntaxe de la déclaration d'une chaîne de caractères constante :

 
Sélectionnez
const char *identificateur = "chaîne";

identificateur est déclaré ici comme étant un pointeur de caractère, puis il est initialisé avec l'adresse de la chaîne de caractères constante "chaîne".

La deuxième est le fait qu'on ne peut pas faire, comme en Pascal, des affectations de chaînes de caractères, ni des comparaisons. Par exemple, si « nom1 » et « nom2 » sont des chaînes de caractères, l'opération :

 
Sélectionnez
nom1=nom2;

n'est pas l'affectation du contenu de nom2 à nom1. C'est une affectation de pointeur : le pointeur nom1 est égal au pointeur nom2 et pointent sur la même chaîne ! Une modification de la chaîne pointée par nom1 entraîne donc la modification de la chaîne pointée par nom2...

De même, le test nom1==nom2 est un test entre pointeurs, pas entre chaînes de caractères. Même si deux chaînes sont égales, le test sera faux si elles ne sont pas au même emplacement mémoire.

Il existe dans la bibliothèque C de nombreuses fonctions permettant de manipuler les chaînes de caractères. Par exemple, la copie d'une chaîne de caractères dans une autre se fera avec les fonctions strcpy et strncpy, la comparaison de deux chaînes de caractères pourra être réalisée à l'aide des fonctions strcmp et strncmp, et la détermination de la longueur d'une chaîne de caractères à l'aide de la fonction strlen. Je vous invite à consulter la documentation de votre environnement de développement ou la bibliographie pour découvrir toutes les fonctions de manipulation des chaînes de caractères. Nous en verrons un exemple d'utilisation dans la section suivante.

4.11. Allocation dynamique de mémoire

Les pointeurs sont surtout utilisés pour créer un nombre quelconque de variables, ou des variables de taille quelconque, en cours d'exécution du programme.

En temps normal, les variables sont créées automatiquement lors de leur définition. Cela est faisable parce que les variables à créer ainsi que leurs tailles sont connues au moment de la compilation (c'est le but des déclarations que d'indiquer la structure et la taille des objets, et plus généralement de donner les informations nécessaires à leur utilisation). Par exemple, une ligne comme :

 
Sélectionnez
int tableau[10000];

signale au compilateur qu'une variable tableau de 10000 entiers doit être créée. Le programme s'en chargera donc automatiquement lors de l'exécution.

Mais supposons que le programme gère une liste de personnes. On ne peut pas savoir à l'avance combien de personnes seront entrées, le compilateur ne peut donc pas faire la réservation de l'espace mémoire automatiquement. C'est au programmeur de le faire. Cette réservation de mémoire (appelée encore allocation) doit être faite pendant l'exécution du programme. La différence avec la déclaration de tableau précédente, c'est que le nombre de personnes et donc la quantité de mémoire à allouer, est variable. Il faut donc faire ce qu'on appelle une allocation dynamique de mémoire.

4.11.1. Allocation dynamique de mémoire en C

Il existe deux principales fonctions C permettant de demander de la mémoire au système d'exploitation et de la lui restituer. Elles utilisent toutes les deux les pointeurs, parce qu'une variable allouée dynamiquement n'a pas d'identificateur étant donné qu'elle n'était a priori pas connue à la compilation, et n'a donc pas pu être déclarée. Les pointeurs utilisés par ces fonctions C n'ont pas de type. On les référence donc avec des pointeurs non typés. Leur syntaxe est la suivante :

 
Sélectionnez
malloc(taille)
free(pointeur)

malloc (abréviation de « Memory ALLOCation ») alloue de la mémoire. Elle attend comme paramètre la taille de la zone de mémoire à allouer et renvoie un pointeur non typé (void *).

free (pour « FREE memory ») libère la mémoire allouée. Elle attend comme paramètre le pointeur sur la zone à libérer et ne renvoie rien.

Lorsqu'on alloue une variable typée, on doit faire un transtypage du pointeur renvoyé par malloc en pointeur de ce type de variable.

Pour utiliser les fonctions malloc et free, vous devez mettre au début de votre programme la ligne :

 
Sélectionnez
#include <stdlib.h>

Son rôle est similaire à celui de la ligne #include <stdio.h>. Vous verrez sa signification dans le chapitre concernant le préprocesseur.

L'exemple suivant va vous présenter un programme C classique qui manipule des pointeurs. Ce programme réalise des allocations dynamiques de mémoire et manipule une liste de structures dynamiquement, en fonction des entrées que fait l'utilisateur. Les techniques de saisies de paramètres présentées dans le premier chapitre sont également revues. Ce programme vous présente aussi comment passer des paramètres par variable, soit pour optimiser le programme, soit pour les modifier au sein des fonctions appelées. Enfin, l'utilisation du mot clef const avec les pointeurs est également illustrée.

Exemple 4-13. Allocation dynamique de mémoire en C
Sélectionnez
#include <stdio.h>   /* Autorise l'utilisation de printf
                        et de scanf. */
#include <stdlib.h>  /* Autorise l'utilisation de malloc
                        et de free. */
#include <string.h>  /* Autorise l'utilisation de strcpy,
                        strlen et de strcmp. */
 
/* Type de base d'un élément de liste de personne. */
typedef struct person
{
    char *name;           /* Nom de la personne. */
    char *address;        /* Adresse de la personne. */
    struct person *next;  /* Pointeur sur l'élément suivant. */
} Person;
 
typedef Person *People;   /* Type de liste de personnes. */
 
/* Fonctions de gestion des listes de personnes : */
 
/* Fonction d'initialisation d'une liste de personne.
   La liste est passée par variable pour permettre son initialisation. */
void init_list(People *lst)
{
   *lst = NULL;
}
 
/* Fonction d'ajout d'une personne. Les paramètres de la personne
   sont passés par variables, mais ne peuvent être modifiés car
   ils sont constants. Ce sont des chaînes de caractères C, qui
   sont donc assimilées à des pointeurs de caractères constants. */
int add_person(People *lst, const char *name, const char *address)
{
    /* Crée un nouvel élément : */
    Person *p = (Person *) malloc(sizeof(Person));
    if (p != NULL)
    {
        /* Alloue la mémoire pour le nom et l'adresse. Attention,
           il faut compter le caractère nul terminal des chaînes : */
        p->name = (char *) malloc((strlen(name) + 1) * sizeof(char));
        p->address = (char *) malloc((strlen(address) + 1) * sizeof(char));
        if (p->name != NULL && p->address != NULL)
        {
            /* Copie le nom et l'adresse : */
            strcpy(p->name, name);
            strcpy(p->address, address);
            p->next = *lst;
            *lst = p;
        }
        else
        {
            free(p);
            p = NULL;
        }
    }
    return (p != NULL);
}
 
/* Fonction de suppression d'une personne.
   La structure de la liste est modifiée par la suppression
   de l'élément de cette personne. Cela peut impliquer la modification
   du chaînage de l'élément précédent, ou la modification de la tête
   de liste elle-même. */
int remove_person(People *lst, const char *name)
{
    /* Recherche la personne et son antécédant : */
    Person *prev = NULL;
    Person *p = *lst;
    while (p != NULL)
    {
        /* On sort si l'élément courant est la personne recherchée : */
        if (strcmp(p->name, name) == 0)
            break;
        /* On passe à l'élément suivant sinon : */
        prev = p;
        p = p->next;
    }
    if (p != NULL)
    {
        /* La personne a été trouvée, on la supprime de la liste : */
        if (prev == NULL)
        {
            /* La personne est en tête de liste, on met à jour
               le pointeur de tête de liste : */
            *lst = p->next;
        }
        else
        {
            /* On met à jour le lien de l'élément précédent : */
            prev->next = p->next;
        }
        /* et on la détruit : */
        free(p->name);
        free(p->address);
        free(p);
    }
    return (p != NULL);
}
 
/* Simple fonction d'affichage. */
void print_list(People const *lst)
{
    Person const *p = *lst;
    int i = 1;
    while (p != NULL)
    {
        printf("Personne %d : %s (%s)\n", i, p->name, p->address);
        p = p->next;
        ++i;
    }
}
 
/* Fonction de destruction et de libération de la mémoire. */
void destroy_list(People *lst)
{
    while (*lst != NULL)
    {
        Person *p = *lst;
        *lst = p->next;
        free(p->name);
        free(p->address);
        free(p);
    }
    return ;
}
 
int main(void)
{
    int op = 0;
    size_t s;
    char buffer[16];
    char name[256];
    char address[256];
    /* Crée une liste de personne : */
    People p;
    init_list(&p);
    /* Utilise la liste : */
    do
    {
        printf("Opération (0 = quitter, 1 = ajouter, 2 = supprimer) ?");
        fgets(buffer, 16, stdin);
        buffer[15] = 0;
        op = 3;
        sscanf(buffer, "%d", &op);
        switch (op)
        {
        case 0:
            break;
        case 1:
            printf("Nom : ");
            fgets(name, 256, stdin);   /* Lit le nom. */
            name[255] = 0;             /* Assure que le caractère nul
                                          terminal est écrit. */
            s = strlen(name);          /* Supprime l'éventuel saut de ligne. */
            if (name[s - 1] == '\n') name[s - 1] = 0;
            /* Même opération pour l'adresse : */
            printf("Adresse : ");
            fgets(address, 256, stdin);
            name[255] = 0;
            s = strlen(address);
            if (address[s - 1] == '\n') address[s - 1] = 0;
            add_person(&p, name, address);
            break;
        case 2:
            printf("Nom : ");
            fgets(name, 256, stdin);
            name[255] = 0;
            s = strlen(name);
            if (name[s - 1] == '\n') name[s - 1] = 0;
            if (remove_person(&p, name) == 0)
            {
                printf("Personne inconnue.\n");
            }
            break;
        default:
           printf("Opération invalide\n");
           break;
        }
        if (op != 0) print_list(&p);
    } while (op != 0);
    /* Détruit la liste : */
    destroy_list(&p);
    return 0;
}

Note : Comme vous pouvez le constater, la lecture des chaînes de caractères saisies par l'utilisateur est réalisée au moyen de la fonction fgets de la bibliothèque C standard. Cette fonction permet de lire une ligne complète sur le flux spécifié en troisième paramètre, et de stocker le résultat dans la chaîne de caractères fournie en premier paramètre. Elle ne lira pas plus de caractères que le nombre indiqué en deuxième paramètre, ce qui permet de contrôler la taille des lignes saisies par l'utilisateur. La fonction fgets nécessite malheureusement quelques traitements supplémentaires avant de pouvoir utiliser la chaîne de caractères lue, car elle n'écrit pas le caractère nul terminal de la chaîne C si le nombre maximal de caractères à lire est atteint, et elle stocke le caractère de saut de ligne en fin de ligne si ce nombre n'est pas atteint. Il est donc nécessaire de s'assurer que la ligne se termine bien par un caractère nul terminal d'une part, et de supprimer le caractère de saut de ligne s'il n'est pas essentiel d'autre part. Ces traitements constituent également un bon exemple de manipulation des pointeurs et des chaînes de caractères.

Ce programme n'interdit pas les définitions multiples de personnes ayant le même nom. Il n'interdit pas non plus la définition de personnes anonymes. Le lecteur pourra essayer de corriger ces petits défauts à titre d'exercice, afin de s'assurer que les notions de pointeur sont bien assimilées. Rappelons que les pointeurs sont une notion essentielle en C et qu'il faut être donc parfaitement familiarisé avec eux.

4.11.2. Allocation dynamique en C++

En plus des fonctions malloc et free du C, le C++ fournit d'autres moyens pour allouer et restituer la mémoire. Pour cela, il dispose d'opérateurs spécifiques : new, delete, new[] et delete[]. La syntaxe de ces opérateurs est respectivement la suivante :

 
Sélectionnez
new type
delete pointeur
new type[taille]
delete[] pointeur

Les deux opérateurs new et new[] permettent d'allouer de la mémoire, et les deux opérateurs delete et delete[] de la restituer.

La syntaxe de new est très simple, il suffit de faire suivre le mot clé new du type de la variable à allouer, et l'opérateur renvoie directement un pointeur sur cette variable avec le bon type. Il n'est donc plus nécessaire d'effectuer un transtypage après l'allocation, comme c'était le cas pour la fonction malloc. Par exemple, l'allocation d'un entier se fait comme suit :

 
Sélectionnez
int *pi = new int;  // Équivalent à (int *) malloc(sizeof(int)).

La syntaxe de delete est encore plus simple, puisqu'il suffit de faire suivre le mot clé delete du pointeur sur la zone mémoire à libérer :

 
Sélectionnez
delete pi;          // Équivalent à free(pi);

Les opérateurs new[] et delete[] sont utilisés pour allouer et restituer la mémoire pour les types tableaux. Ce ne sont pas les mêmes opérateurs que new et delete, et la mémoire allouée par les uns ne peut pas être libérée par les autres. Si la syntaxe de delete[] est la même que celle de delete, l'emploi de l'opérateur new[] nécessite de donner la taille du tableau à allouer. Ainsi, on pourra créer un tableau de 10000 entiers de la manière suivante :

 
Sélectionnez
int *Tableau=new int[10000];

et détruire ce tableau de la manière suivante :

 
Sélectionnez
delete[] Tableau;

L'opérateur new[] permet également d'allouer des tableaux à plusieurs dimensions. Pour cela, il suffit de spécifier les tailles des différentes dimensions à la suite du type de donnée des élements du tableau, exactement comme lorsque l'on crée un tableau statiquement. Toutefois, seule la première dimension du tableau peut être variable, et les dimensions deux et suivantes doivent avoir une taille entière positive et constante. Par exemple, seule la deuxième ligne de l'exemple qui suit est une allocation dynamique de tableau valide :

 
Sélectionnez
int i=5, j=3;
int (*pi1)[3] = new int[i][3];    // Alloue un tableau de i lignes de trois entiers.
int (*pi2)[3] = new int[i][j];    // Illégal, j n'est pas constant.

Si l'on désire réellement avoir des tableaux dont plusieurs dimensions sont de taille variable, on devra allouer un tableau de pointeurs et, pour chaque ligne de ce tableau, allouer un autre tableau à la main.

Note : Il est important d'utiliser l'opérateur delete[] avec les pointeurs renvoyés par l'opérateur new[] et l'opérateur delete avec les pointeurs renvoyés par new. De plus, on ne devra pas non plus mélanger les mécanismes d'allocation mémoire du C et du C++ (utiliser delete sur un pointeur renvoyé par malloc par exemple). En effet, le compilateur peut allouer une quantité de mémoire supérieure à celle demandée par le programme afin de stocker des données qui lui permettent de gérer la mémoire. Ces données peuvent être interprétées différemment pour chacune des méthodes d'allocation, si bien qu'une utilisation erronée peut entraîner soit la perte des blocs de mémoire, soit une erreur, soit un plantage.

L'opérateur new[] alloue la mémoire et crée les objets dans l'ordre croissant des adresses. Inversement, l'opérateur delete[] détruit les objets du tableau dans l'ordre décroissant des adresses avant de libérer la mémoire.

La manière dont les objets sont construits et détruits par les opérateurs new et new[] dépend de leur nature. S'il s'agit de types de base du langage ou de structures simples, aucune initialisation particulière n'est faite. La valeur des objets ainsi créés est donc indéfinie, et il faudra réaliser l'initialisation soi-même. Si, en revanche, les objets créés sont des instances de classes C++, le constructeur de ces classes sera automatiquement appelé lors de leur initialisation. C'est pour cette raison que l'on devra, de manière générale, préférer les opérateurs C++ d'allocation et de désallocation de la mémoire aux fonctions malloc et free du C. Ces opérateurs ont de plus l'avantage de permettre un meilleur contrôle des types de données et d'éviter un transtypage. Les notions de classe et de constructeur seront présentées en détail dans le chapitre traitant de la couche objet du C++.

Lorsqu'il n'y a pas assez de mémoire disponible, les opérateurs new et new[] peuvent se comporter de deux manières selon l'implémentation. Le comportement le plus répandu est de renvoyer un pointeur nul. Cependant, la norme C++ indique un comportement différent : si l'opérateur new manque de mémoire, il doit appeler un gestionnaire d'erreur. Ce gestionnaire ne prend aucun paramètre et ne renvoie rien.

Selon le comportement de ce gestionnaire d'erreur, plusieurs actions peuvent être faites :

  • soit ce gestionnaire peut corriger l'erreur d'allocation et rendre la main à l'opérateur new ( le programme n'est donc pas terminé), qui effectue une nouvelle tentative pour allouer la mémoire demandée ;
  • soit il ne peut rien faire. Dans ce cas, il peut mettre fin à l'exécution du programme ou lancer une exception std::bad_alloc, qui remonte alors jusqu'à la fonction appelant l'opérateur new. C'est le comportement du gestionnaire installé par défaut dans les implémentations conformes à la norme.

L'opérateur new est donc susceptible de lancer une exception std::bad_alloc. Voir le Chapitre 9 pour plus de détails à ce sujet.

Il est possible de remplacer le gestionnaire d'erreur appelé par l'opérateur new à l'aide de la fonction std::set_new_handler, déclarée dans le fichier d'en-tête new. Cette fonction attend en paramètre un pointeur sur une fonction qui ne prend aucun paramètre et ne renvoie rien. Elle renvoie l'adresse du gestionnaire d'erreur précédent.

Note : La fonction std::set_new_handler et la classe std::bad_alloc font partie de la bibliothèque standard C++. Comme leurs noms l'indiquent, ils sont déclarés dans l'espace de nommage std::, qui est réservé pour les fonctions et les classes de la bibliothèque standard. Voyez aussi le Chapitre 11 pour plus de détails sur les espaces de nommages. Si vous ne désirez pas utiliser les mécanismes des espaces de nommage, vous devrez inclure le fichier d'en-tête new.h au lieu de new.

Attendez vous à ce qu'un jour, tous les compilateurs C++ lancent une exception en cas de manque de mémoire lors de l'appel à l'opérateur new, car c'est ce qu'impose la norme. Si vous ne désirez pas avoir à gérer les exceptions dans votre programme et continuer à recevoir un pointeur nul en cas de manque de mémoire, vous pouvez fournir un deuxième paramètre de type std::nothrow_t à l'opérateur new. La bibliothèque standard définit l'objet constant std::nothrow à cet usage.

Les opérateurs delete et delete[] peuvent parfaitement être appelés avec un pointeur nul en paramètre. Dans ce cas, ils ne font rien et redonnent la main immédiatement à l'appelant. Il n'est donc pas nécessaire de tester la non nullité des pointeurs sur les objets que l'on désire détruire avant d'appeler les opérateurs delete et delete[].

4.12. Pointeurs et références de fonctions

4.12.1. Pointeurs de fonctions

Il est possible de faire des pointeurs de fonctions. Un pointeur de fonction contient l'adresse du début du code binaire constituant la fonction. Il est possible d'appeler une fonction dont l'adresse est contenue dans un pointeur de fonction avec l'opérateur d'indirection *.

Pour déclarer un pointeur de fonction, il suffit de considérer les fonctions comme des variables. Leur déclaration est identique à celle des tableaux, en remplaçant les crochets par des parenthèses :

 
Sélectionnez
type (*identificateur)(paramètres);

type est le type de la valeur renvoyée par la fonction, identificateur est le nom du pointeur de la fonction et paramètres est la liste des types des variables que la fonction attend comme paramètres, séparés par des virgules.

Exemple 4-14. Déclaration de pointeur de fonction
Sélectionnez
int (*pf)(int, int);   /* Déclare un pointeur de fonction. */

pf est un pointeur de fonction attendant comme paramètres deux entiers et renvoyant un entier.

Il est possible d'utiliser typedef pour créer un alias du type pointeur de fonction :

 
Sélectionnez
typedef int (*PtrFonct)(int, int);
PtrFonct pf;

PtrFonct est le type des pointeurs de fonctions.

Si f est une fonction répondant à ces critères, on peut alors initialiser pf avec l'adresse de f. De même, on peut appeler la fonction pointée par pf avec l'opérateur d'indirection.

Exemple 4-15. Déréférencement de pointeur de fonction
Sélectionnez
#include <stdio.h>    /* Autorise l'emploi de scanf et de printf. */
 
int f(int i, int j)   /* Définit une fonction. */
{
    return i+j;
}
 
int (*pf)(int, int);  /* Déclare un pointeur de fonction. */
 
int main(void)
{
    int l, m;         /* Déclare deux entiers. */
    pf = &f;          /* Initialise pf avec l'adresse de la fonction f. */
    printf("Entrez le premier entier : ");
    scanf("%u",&l);   /* Initialise les deux entiers. */
    printf("\nEntrez le deuxième entier : ");
    scanf("%u",&m);
 
/* Utilise le pointeur pf pour appeler la fonction f
   et affiche le résultat : */
 
    printf("\nLeur somme est de : %u\n", (*pf)(l,m));
    return 0;
}

L'intérêt des pointeurs de fonction est de permettre l'appel d'une fonction parmi un éventail de fonctions au choix.

Par exemple, il est possible de faire un tableau de pointeurs de fonctions et d'appeler la fonction dont on connaît l'indice de son pointeur dans le tableau.

Exemple 4-16. Application des pointeurs de fonctions
Sélectionnez
#include <stdio.h>  /* Autorise l'emploi de scanf et de printf. */
 
/* Définit plusieurs fonctions travaillant sur des entiers : */
 
int somme(int i, int j)
{
    return i+j;
}
 
int multiplication(int i, int j)
{
    return i*j;
}
 
int quotient(int i, int j)
{
    return i/j;
}
 
int modulo(int i, int j)
{
    return i%j;
}
 
typedef int (*fptr)(int, int);
fptr ftab[4];
 
int main(void)
{
    int i,j,n;
    ftab[0]=&somme;          /* Initialise le tableau de pointeur */
    ftab[1]=&multiplication; /* de fonctions. */
    ftab[2]=&quotient;
    ftab[3]=&modulo;
    printf("Entrez le premier entier : ");
    scanf("%u",&i);          /* Demande les deux entiers i et j. */
    printf("\nEntrez le deuxième entier : ");
    scanf("%u",&j);
    printf("\nEntrez la fonction : ");
    scanf("%u",&n);          /* Demande la fonction à appeler. */
    if (n < 4)
        printf("\nRésultat : %u.\n", (*(ftab[n]))(i,j) );
    else
        printf("\nMauvais numéro de fonction.\n");
    return 0;
}

4.12.2. Références de fonctions

Les références de fonctions sont acceptées en C++. Cependant, leur usage est assez limité. Elles permettent parfois de simplifier les écritures dans les manipulations de pointeurs de fonctions. Mais comme il n'est pas possible de définir des tableaux de références, le programme d'exemple donné ci-dessus ne peut pas être récrit avec des références.

Les références de fonctions peuvent malgré tout être utilisées à profit dans le passage des fonctions en paramètre dans une autre fonction. Par exemple :

 
Sélectionnez
#include <stdio.h>  // Autorise l'emploi de scanf et de printf.
 
// Fonction de comparaison de deux entiers :
 
int compare(int i, int j)
{
    if (i<j) return -1;
    else if (i>j) return 1;
    else return 0;
}
 
// Fonction utilisant une fonction en tant que paramètre :
 
void trie(int tableau[], int taille, int (&fcomp)(int, int))
{
    // Effectue le tri de tableau avec la fonction fcomp.
    // Cette fonction peut être appelée comme toute les autres
    // fonctions :
    printf("%d", fcomp(2,3));
      &vellip;
    return ;
}
 
int main(void)
{
    int t[3]={1,5,2};
    trie(t, 3, compare);   // Passage de compare() en paramètre.
    return 0;
}

4.13. Paramètres de la fonction main - ligne de commande

L'appel d'un programme se fait normalement avec la syntaxe suivante :

 
Sélectionnez
nom param1 param2 [...]

nom est le nom du programme à appeler et param1, etc. sont les paramètres de la ligne de commande. De plus, le programme appelé peut renvoyer un code d'erreur au programme appelant (soit le système d'exploitation, soit un autre programme). Ce code d'erreur est en général 0 quand le programme s'est déroulé correctement. Toute autre valeur indique qu'une erreur s'est produite en cours d'exécution.

La valeur du code d'erreur est renvoyée par la fonction main. Le code d'erreur doit toujours être un entier. La fonction main peut donc (et même normalement doit) être de type entier :

 
Sélectionnez
int main(void) ...

Les paramètres de la ligne de commandes peuvent être récupérés par la fonction main. Si vous désirez les récupérer, la fonction main doit attendre deux paramètres :

le premier est un entier, qui représente le nombre de paramètres ;

le deuxième est un tableau de chaînes de caractères (donc en fait un tableau de pointeurs, ou encore un pointeur de pointeurs de caractères).

Les paramètres se récupèrent avec ce tableau. Le premier élément pointe toujours sur la chaîne donnant le nom du programme. Les autres éléments pointent sur les paramètres de la ligne de commande.

Exemple 4-17. Récupération de la ligne de commande
Sélectionnez
#include <stdio.h>     /* Autorise l'utilisation des fonctions */
                       /* printf et scanf. */
 
int main(int n, char *params[])  /* Fonction principale. */
{
    int i;
 
    /* Affiche le nom du programme : */
    printf("Nom du programme : %s.\n",params[0]);
 
    /* Affiche la ligne de commande : */
    for (i=1; i<n; ++i)
        printf("Argument %d : %s.\n",i, params[i]);
    return 0;           /* Tout s'est bien passé : on renvoie 0 ! */
}

4.14. DANGER

Les pointeurs sont, comme on l'a vu, très utilisés en C/C++. Il faut donc bien savoir les manipuler.

Mais ils sont très dangereux, car ils permettent d'accéder à n'importe quelle zone mémoire, s'ils ne sont pas correctement initialisés. Dans ce cas, ils pointent n'importe où. Accéder à la mémoire avec un pointeur non initialisé peut altérer soit les données du programme, soit le code du programme lui-même, soit le code d'un autre programme ou celui du système d'exploitation. Cela conduit dans la majorité des cas au plantage du programme, et parfois au plantage de l'ordinateur si le système ne dispose pas de mécanismes de protection efficaces.

VEILLEZ À TOUJOURS INITIALISER LES POINTEURS QUE VOUS UTILISEZ.

Pour initialiser un pointeur qui ne pointe sur rien (c'est le cas lorsque la variable pointée n'est pas encore créée ou lorsqu'elle est inconnue lors de la déclaration du pointeur), on utilisera le pointeur prédéfini NULL.

VÉRIFIEZ QUE TOUTE DEMANDE D'ALLOCATION MÉMOIRE A ÉTÉ SATISFAITE.

La fonction malloc renvoie le pointeur NULL lorsqu'il n'y a plus ou pas assez de mémoire. Le comportement des opérateurs new et new[] est différent. Théoriquement, ils doivent lancer une exception si la demande d'allocation mémoire n'a pas pu être satisfaite. Cependant, certains compilateurs font en sorte qu'ils renvoient le pointeur nul du type de l'objet à créer.

S'ils renvoient une exception, le programme sera arrêté si aucun traitement particulier n'est fait. Bien entendu, le programme peut traiter cette exception s'il le désire, mais en général, il n'y a pas grand chose à faire en cas de manque de mémoire. Vous pouvez consulter le chapitre traitant des exceptions pour plus de détails à ce sujet.

Dans tous les cas,

LORSQU'ON UTILISE UN POINTEUR, IL FAUT VÉRIFIER S'IL EST VALIDE

(par un test avec NULL ou le pointeur nul, ou en analysant l'algorithme). Cette vérification inclut le test de débordement lors des accès aux chaînes de caractères et aux tableaux. Cela est extrêmement important lorsque l'on manipule des données provenant de l'extérieur du programme, car on ne peut dans ce cas pas supposer que ces données sont valides.


précédentsommairesuivant

Copyright © 2003 Christian Casteyde. Permission vous est donnée de copier, distribuer et modifier ce document selon les termes de la licence GNU pour les documentations libres, version 1.1 ou toute autre version ultérieure publiée par la Free Software Foundation. Une copie de cette licence est incluse dans l'annexe intitulée "GNU Free Documentation License". Vous trouverez également une traduction non officielle de cette licence dans l'annexe intitulée "Licence de documentation libre GNU".