• <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    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 :

    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 :

    identificateur[n]
    et :
    *(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

    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 :

    &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 :
    *&tableau == tableau
    puisque les opérateurs * et & sont conjugués, d'où :
    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 :

    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 :
    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

    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;
    }

    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    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) :

    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

    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 :

    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 :

    sizeof(int)

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

    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 :

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

    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    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 :

    const int *pi;
    peut être réécrite de la manière suivante :
    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 :
    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 :

    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 :

    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 :

    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 :

    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 :

      • les types source Ts et destination Td sont compatibles ;

      • il existe un nombre entier strictement positif N tel que, quel que soit j supérieur ou égal à N, on ait :

        • si const apparaît dans cvs,j, alors const apparaît dans cvd,j ;

        • si volatile apparaît dans cvs,j, alors volatile apparaît dans cvd,j ;

        • et tel que, quel que soit 0<k<N, 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 :

    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 :

    "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 :
    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 :

    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.


    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    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

    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

    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

    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 :

    type &identificateur [, type &identificateur [...]]

    Exemple 4-7. Passage de paramètre par référence en C++

    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

    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

    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.


    votre commentaire
  • <script type="text/javascript" src="http://ads.allotraffic.com/bandeau?id=26856"></script> <script type="text/javascript" src="http://nodes.reactivpub.fr/scripts/slidein.php?idsite=|8764|&theme="></script>

    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 :

    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 :
    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 :

    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.


    votre commentaire