Notez que le texte qui suit, sans être inintéressant, ne reflète pas la complexité et la richesse des architectures matérielles contemporaines, ayant écrit au tout début des années 2000. De plus, pour discuter d'adresses et de pointeurs, il propose à quelques reprises du code non-portable. Soyez prudent(e)s.
Nous allons maintenant ouvrir une parenthèse pour présenter comment s'organise, en mémoire, le code objet généré par la compilation.
À cet effet, nous délaisserons la mécanique de la génération d'un exécutable et celle de l'exécution d'un programme pour parler un peu d'adresses et de pointeurs dans les langages C et C++.
Avertissement important: les idées et techniques proposées ici sont primitives et proposent des modèls de programmation de très bas niveau, au sens de très près du matériel. En général, c'est une mauvaise idée d'aller aussi bas dans une démarche de programmation – les compilateurs génèrent souvent du meilleur code machine que ne le font les humains, et le code de base niveau est très peu portable. Il est préférable d'avoir un peu d'expérience derrière la cravate avant de mélanger la programmation de haut et de bas niveau, et il est préférable de limiter la programmation de bas niveau aux rares cas où celle-ci est véritablement avantageuse.
Autre avertissement important: ce document utilisera à l'occasion le mot objet pour parler à la fois de variable, de constante, de sous-programme... Je vous en prie, ne confondez pas le mot objet utilisé comme généralisation avec l'idée d'objet dans une démarche orientée objet.
Un petit rappel nous sera utile, avant d'aller plus loin, relativement aux adresses. Le mot adresse devient de plus en plus important alors que nous progressons et nous devrons être à l'aise avec lui pour bien comprendre le sujet à l'étude.
À garder à l'esprit, donc :
Qu'est-ce qui, dans un programme, a une adresse? La réponse est simple: absolument tout, ce qui est en mémoire, les instructions comme les données.
Prenons un (très) petit exemple pour nous mettre à l'aise. Les numéros attribués, sous forme de commentaires, à la droite de chaque ligne, nous serviront plus loin de référence. Confronté à ce code, le compilateur devra accomplir un certain nombre de tâches. Nous essaierons de les détailler pour clarifier le processus qui mènera à la génération de l'exécutable correspondant. |
|
Dans les langages C et C++, un contexte est dénoté par une parie balancée d'accolades ouvrante { et fermante }. La portée d'un objet est délimitée par le contexte dans lequel celui-ci est déclaré.
L'accolade ouvrante { suivant l'en-tête de la fonction main() débute la séquence d'instructions qui constituent la définition de ce sous-programme au rôle particulier. Nous devrons passer outre les détails propres à ce type d'instruction pour le moment (n'ayez crainte : nous reviendrons sous peu à la charge!), mais nous observerons toutefois que l'accolade ouvrante { dénote le début d'un contexte (ou d'un bloc, en langage courant).
L'idée de définir un contexte est riche, mais nous intéresse ici dans le sens qu'elle définit la durée de vie des objets qui lui appartiennent, donc la portée de ces objets.
On nomme portée d'un objet l'étendue sur laquelle cet objet existe – sa durée de vie.
En effet, un objet déclaré dans un contexte a une durée de vie déterminée par l'étendue de ce contexte. Dans notre petit programme en exemple, les variables entières a, b et c ont une portée allant du moment de leur déclaration (1) à la fin du contexte dans lequel elles ont été déclarées (3).
Les objets définis dans un contexte ne naissent pas tous en même temps. Chacun est construit là où il est défini dans le programme, tous les objets définis dans un même contexte meurent tous à la fin de ce contexte.
Même si certains compilateurs supportent des formes non conformes au standard ISO de C++, main() ne peut avoir que deux formes légales, soit int main() si le programme n'est pas préoccupé par ses paramètres et int main(int argc, const char *argv[]) si les paramètres lui sont importants (des dérivations locales sont possibles mais le type doit demeurer int). Une fonction main() légale n'est jamais void en C++.
Pour s'exécuter, chaque programme doit avoir un (et un seul!) point de départ. En C comme en C++, ce point de départ est la fonction main(), qui doit par conséquent être unique pour tout l'exécutable. Chaque exécutable C ou C++ doit avoir une (et une seule) fonction main() pour l'ensemble des fichiers .obj dont il est constitué.
Pour générer ce contexte, le compilateur doit d'abord insérer le code requis pour débuter un programme[2], puis décider d'un point bien précis dans le code qu'il est le point de départ de la fonction main() en question.
Le registre IP (pointeur d'instruction) contiendra, au début de l'exécution du programme, l'adresse de la première instruction à partir de cet endroit en mémoire. Ainsi, l'exécution du programme débutera par le traitement de sa toute première instruction, ce qui est exactement l'effet voulu.
Il peut paraître anodin d'indiquer adresse de la première instruction à partir du début du programme. Pourtant, cet énoncé est lourd de signification.
Mettons-nous en situation. Le compilateur génère du code objet pour un programme, et doit décider, par exemple, des tailles des différentes variables rencontrées. Ceci est possible parce que toute variable est d'un certain type, et parce que le type d'une variable en indique la taille. Mais...
Question piège :Est-il possible de déterminer, à la compilation, l'adresse réelle de chaque variable – celle où la variable se trouvera en mémoire? Pourquoi?
À l'exécution, c'est une autre histoire...
La réponse est non : en effet, pour savoir l'adresse réelle d'une variable (ou d'une constante, ou d'une fonction, ou...), il faut savoir où le programme se trouvera en mémoire une fois chargé du disque rigide. Et cela n'est pas connu du compilateur alors qu'il traverse le programme pour générer le code objet correspondant.
Le compilateur, lors de la génération du code objet, doit donc se limiter à utiliser des adresses relatives au début du programme plutôt que des adresses absolues en mémoire.
On nomme adresse relative d'un objet le déplacement (en anglais : Offset) d'un objet à partir du début d'un programme.
L'éditeur de liens devra se limiter, lui aussi, à des adresses relatives lors de la génération de l'exécutable, puisqu'il se trouve dans la même situation que le compilateur – il ne sait pas où sera, en bout de ligne, le programme une fois chargé en mémoire.
Il existe un programme (le chargeur, en anglais Loader) qu'on ne voit à peu près jamais qui sert à charger les programmes en mémoire.
C'est lui qui trouvera un endroit en mémoire pour le programme. C'est donc lui qui, lors du chargement d'un programme en mémoire, finira le travail calculera les adresses absolues à partir de l'adresse où se trouve effectivement le programme et des adresses relatives trouvées à l'intérieur.
On nomme adresse absolue d'un objet l'endroit en mémoire où se trouve cet objet. L'adresse absolue d'une donnée X se calcule comme proposé à droite, où ADDRX signifie adresse absolue de X et OFFSX signifie position de X par rapport au début du programme (ces notations ne sont pas standard). |
|
L'instruction (ou suite d'instructions) int a= 3, b= 5, c; est un suite de déclarations de variables, avec initialisation automatique pour certaines d'entre elles. Nous savons que ces variables existeront jusqu'à la fin de leur contexte (instruction marquée du commentaire (3)), mais que savons-nous d'autre à leur sujet?
Nous savons que tout objet a une taille. Ceci implique que tout objet occupe un espace, et dans le cas qui nous intéresse cet espace est occupé en mémoire.
Occuper un espace signifie aussi, incidemment, être quelque part, donc avoir une adresse, et être constitué de quelque chose, avoir une valeur, un contenu. Le vide n'existe pas en mémoire: il y a des zéros et des uns, mais pas de riens[3].
Trois propriétés fondamentales
Et ces trois considérations (localité, espace occupé et constitution, ou encore adresse, taille et valeur) sont toutes essentielles. Cela peut paraître un peu philosophique, je vous l'accorde, mais c'est aussi fort concret. Pour un compilateur, ces questions sont si importantes que leur répondre est au centre de ses préoccupations.
Le compilateur, en rencontrant par exemple int a;, doit s'assurer qu'en quelque part se trouvera un espace suffisant pour mettre ce qui sera, à partir du moment de cette déclaration, a. Pour cela, il lui faut connaître l'espace requis pour ce que sera cet objet.
Cette remarque tient pour tous les types, pas seulement pour les types primitifs.
Heureusement, C++ étant un langage typé, le compilateur sait immédiatement qu'un objet de type int doit occuper un espace de sizeof(int) bytes (ici : 32 bits). Muni de ce savoir, il est en mesure de s'assurer que les prochains 32 bits[4] soient attribués pour loger cette variable.
Toute mention de a dans son contexte signifiera donc une interaction avec l'espace de 32 bits en mémoire qui lui a été attribué lors de sa déclaration. L'adresse de a sera celle du premier des quatre bytes qui la constituent.
L'affectation de la valeur 3 à cette variable implique aussi une part de mécanique. Ainsi, il faudra au compilateur déposer la valeur 3 sur 32 bits à l'endroit attribué à a (par exemple, à l'aide de l'instruction assembleur MOV).
Rappel : taille des littéraux
Petit rappel: toute littéral a une taille (il faut bien que le compilateur lui réserve un espace dans la mémoire du programme, après tout!). Par défaut, les littéraux entiers comme 3 sont de type int et occupent un espace de sizeof(int) bytes, et ce qu'ils soient signés ou non (donc sizeof(3)==sizeof(3U)).
Il est possible de s'assurer que 3 soit de type long plutôt que de type int (surtout sur les plateformes où ces deux types sont de taille différente) en l'écrivant sous la forme 3L plutôt que 3.
Par défaut, les littéraux à virgule flottante comme 1.25 sont de type double et occupent un espace de sizeof(double) bytes.
Il est possible de s'assurer que 1.25 soit de type float plutôt que de type double en l'écrivant sous la forme 1.25f plutôt que sous la forme 1.25.
Notez que ceci est une illustration incorrecte et imprécise, qui mêle code et données au même endroit. Des illustrations plus correctes suivent sous peu.
Le processus de génération du code machine – les humains que nous sommes utiliseront bien sûr la notation assembleur plutôt que la notation en code machine – correspondant au code objet des instructions annotées des commentaires (0) et (1) produira probablement[5] en mémoire quelque chose ressemblant conceptuellement à ceci.
Portez une attention particulière aux items suivants :
Ne confondez pas cette notation d'adresse d'un objet avec celle utilisée pour les paramètres passés par référence. Les notations et les concepts sont de proches cousins (on parle du même symbole!), mais il y a des nuances quant aux effets et quant au sens de l'une et de l'autre en fonction de l'endoit oû elles sont utilisées, mais pas utilisé aux mêmes endroits
Un portrait plus honnête et plus réaliste du résultat de cette génération de code serait plutôt la combinaison de ceci...
...qui représente la séquence des instructions à accomplir, segment à travers lequel se déplacera le registre IP[6], et de...
...qui présente l'attribution des espaces pour les données. Il manque bien sûr des détails pour que le portrait soit complet, mais ceci devrait vous aider à vous faire une image préliminaire décente du mécanisme.
À retenir: le code et les données ne peuvent être générés au même endroit. Le registre IP va d'une instruction à l'autre dans un programme et ne peut en plus tenir compte de la position des données pour les éviter.
Nous l'avons vu, tout objet (en particulier, toute variable) a une adresse, et occupe un certain espace, les instructions comme le reste. Mais que trouve-t-on dans une variable si celle-ci n'a pas été initialisée?
Quelques valeurs par défaut classiques: 0xcdcdcdcd, 0xdeadcode ou 0xdeadbeef... on retrouve souvent dans ces variables des blagues d'informaticien(ne)s, semblables à des oeufs de Pâques, en anglais Easter Eggs, d'un goût suspect, un peu comme le début de tout bytecode Java qui devait être 0xcafebabe.
La réponse peut varier. Certains systèmes s'assureront d'avoir des bytes de valeur 0 par défaut dans tout objet servant de donnée dans un programme. D'autres inséreront des valeurs reconnaissables par un dévermineur. D'autres encore n'offriront aucune garantie. En général, C et C++ ont comme philosophie de ne faire payer à un programme que ce que les programmeuses et les programmeurs ont accepté de payer, et les compilateurs pour ces langages n'incluront aucune initialisation de valeurs qui n'ai été explicitement demandée dans le programme. Plusieurs compilateurs initialiseront des valeurs reconnaissables lors de compilations en mode de mise au point (mode Debug) et ne le feront pas en mode de production (mode Release). En pratique, il faut absolument éviter de présumer la valeur d'une variable n'ayant pas été initialisée.
Si vous tracez votre code à l'aide d'un dévermineur, vous verrez sans l'ombre d'un doute que le contenu des variables est à peu près aléatoire jusqu'au moment où votre code y déposera délibérément un contenu raisonnable.
Pour reprendre notre exemple, le contenu de la variable c à la ligne (1) est indéterminé au sens de votre programme. Il s'y trouve une valeur, mais celle-ci peut changer d'une exécution à l'autre; par conséquent, votre programme ne devrait faire aucune présomption au sujet de cette valeur, sinon qu'il ne peut compter sur elle.
L'instruction (ou suite d'instructions) à la ligne (2) du code source peut s'exprimer comme suit: calculer la somme de a et de b, et déposer ce résultat dans c. Et pour nous, les mortels, ceci est suffisant.
Pour ce qui est du code machine, il y a un besoin sérieux de clarification et de développement additionnel pour en arriver à un bout de code utilisable.Détaillons donc un peu plus notre algorithme.
Il nous faut :
Pour arriver à un résultat, nous devons donc faire preuve d'une certaine prudence. Le processeur possède, en ses registres, tout ce dont il a besoin pour traiter ces opérations, même décomposées en leur forme la plus simple.
Le compilateur fonctionnera donc probablement comme suit.
Problème : |
Additionner deux entiers a et b, et déposer le résultat de cette addition dans un autre entier c. |
||
---|---|---|---|
Question : |
Quelle est la taille du plus gros des opérandes? |
Réponse : |
32 bits |
Opérations : |
Déposer le contenu de a dans un registre 32 bits, disons EAX, par MOV EAX,[a], où [a] signifie ce qui se trouve à l'adresse propre à l'objet de compilation a, donc les 32 bits débutant à cette adresse du fait de son dépôt dans un registre de cette taille. Ce registre deviendra une variable temporaire jouant le rôle de a pour fins de l'addition a+b. Déposer le contenu de b dans un registre 32 bits, disons EDX, par MOV EDX,[b], où [b] signifie ce qui se trouve à l'adresse propre à l'objet de compilation b, donc les 32 bits débutant à cette adresse du fait de son dépôt dans un registre de cette taille. Ce registre deviendra une variable temporaire jouant le rôle de b pour fins de l'addition a+b. Utiliser l'opération ADD EAX,EDX qui déposera le résultat de l'opération a+b dans le registre EAX, variable temporaire jouant un nouveau rôle. Déposer le contenu du résultat de cette opération (se trouvant dans le registre EAX) dans c, soit à l'adresse propre à l'objet de compilation c, lui aussi d'une taille de 32 bits. |
Au total, donc, on peut s'attendre à quelque chose comme[7]: Tout cela peut sembler fort complexe (c'est surtout un peu long), mais au fond tout cela est plus simple que les regards de plus haut niveau auxquels nous sommes maintenant habitués. Regarder de plus près la mécanique interne d'un programme occulte moins le mécanisme des opérations, même les plus élémentaires. |
|
Le contexte, tel que mentionné précédemment, se termine avec la rencontre de l'accolade fermante dans le code source. Les variables déclarées dans le contexte existent du point de leur déclaration (1) jusqu'à celui, fatal, de la fin de leur contexte (3).
La gestion du contexte peut sembler relativement complexe. En effet: comment forcer le programme à ne pas reconnaître les variables automatiques, locales à une fonction, lorsqu'à l'extérieur du contexte où celles-ci sont déclarées si, au fond, les variables sont à une adresse en mémoire et si toutes les adresses sont simplement des entiers sur 32 bits?
Il existe même en C et en C++ 03 un mot clé auto pour ce type de variable, qui n'est pratiquement jamais utilisé puisque les variables automatiques le sont implicitement (ce mot clé n'est dans le langage que par souci d'homogénéité). Depuis C++ 11, ce mot clé a enfin trouvé un usage pertinent.
En langage C et en C++, les variables locales aux fonctions sont aussi appelées variables automatiques, en vertu de leur cycle de vie. En effet, elles commencent automatiquement à exister lors de leur déclaration, et cessent automatiquement d'exister à la fin de leur contexte.
Nous arrivons au noeud derrière la magie du mécanisme: grâce à ce qu'on appelle la pile d'exécution, plusieurs fois mentionnée précédemment, les variables automatiques en question n'existent même pas à l'extérieur de leur contexte.
Nous verrons, tel que promis, comment tout cela fonctionne lorsque nous attaquerons le sujet de la pile d'exécution.
En C++, il est aisé d'accéder à l'adresse d'un objet: c'est une simple question de précéder cet objet d'un &.
Ne confondez pas l'esperluette (&) précédant un objet, dont le sens est adresse de cet objet, avec celle précédant un paramètre dans un prototype de sous-programme, dont le sens est ce paramètre est passé par référence, ou avec celle le nom d'un objet lors de sa déclaration, dont le sens est cet objet est une référence à un autre.
// inclusions et using...
void f (int &i) // i est un int passé par référence
{
i *= 2; // modifie le référé
}
void g ()
{
int x = 3;
int &r = x; // r est une référence à x
cout << x << ' ' << r << endl; // affiche 3 3
f(x);
cout << x << ' ' << r << endl; // affiche 6 6
int *p = &x; // p contient l'adresse de x
*p = 4; // 4 est déposé là où pointe p
cout << x << ' ' << r << ' ' << *p << endl; // affiche 4 4 4
}
L'exemple ci-dessus montre quelques cas type d'utilisation de l'esperluette dans une notation C++. Notez que la notation par laquelle ce symbole signifie référence à (variable r de la procédure g() et paramètre i de la procédure f()) est illégale en langage C.
Prenons par exemple le programme ci-dessous :
#include <iostream>
int main()
{
using namespace std;
int a = 3; // a est un entier signé sur 32 bits, de valeur 3
cout << a << ' ' // affiche le contenu de a, donc 3
<< &a << endl; // affiche l' adresse de a, ex: 0xa88bc
}
Afficher l'adresse d'un objet présentera à l'écran un gros nombre quelconque, habituellement en notation hexadécimale. Ce nombre est très important puisqu'il dénote l'endroit où se trouve l'objet en question, mais ne fait pas beaucoup de sens pour un usager.
Lorsqu'on manipule une adresse, donc lorsqu'on manipule le lieu en mémoire d'un objet d'une certaine taille, on s'intéresse souvent à son contenu. Ainsi, si a est un int, alors &a signifie adresse de l'entier signé sur sizeof(int) bytes nommé a, et *(&a) signifie contenu sur de l'entier signé sur sizeof(int) bytes débutant à l'adresse de a. Ainsi, a est équivalent à *(&a).
Reprenant notre exemple, le programme suivant...
#include <iostream>
int main()
{
using namespace std;
int a = 3;
cout << a << ' ' << &a << ' ' << *(&a) << endl;
}
...affichera 3 0xa88bc 3.
Jusqu'ici, l'intérêt de savoir manipuler et accéder à des adresses peut ne pas être évident. Il nous manque un peu de carburant pour bien saisir la puissance du concept; cela sera plus apparent à l'aide d'un outil spécialisé dans la manipulation d'adresses.
Un outil bien spécial, donc, existe dans bien des langages (C et C++ en particulier) pour manipuler des adresses. Cet outil se nomme le pointeur.
Un pointeur contient et permet de manipuler l'adresse d'un objet d'un type donné.
Muni de pointeurs, on est en mesure d'écrire à une adresse précise en mémoire, de même que de lire le contenu d'une adresse spécifique. On peut aussi accomplir certaines manoeuvres assez spéciales.
Si on veut déclarer un pointeur p vers un int, on écrira int *p;. Le type de p n'est alors pas int mais bien pointeur vers un int. Remarquez que si un astérisque précède une variable lors de sa déclaration, cela signifie que cette variable sera un pointeur. Ainsi, dans les déclarations suivantes, a est un short, b est un pointeur vers un short et c est un short. |
|
Ainsi, a et b ne sont pas du même type: a est un short, donc un entier signé sur sizeof(short) bytes, alors que b est un pointeur vers un short, donc une adresse, celle d'un entier non signé dont la taille correspond à celle du mot mémoire.
Si p est de type pointeur vers un int, alors :
Il y a des nuances à apporter ici, surtout en ce qui a trait aux itérateurs, mais remettons ces nuances à un autre moment et digérons une idée à la fois.
Remarquez que si un astérisque précède un objet ailleurs que lors de sa déclaration, cela signifie qu'on accède au contenu pointé par cet objet, donc à ce qui se trouve à l'adresse qu'il contient. Accéder ainsi à l'objet pointé apr un pointeur est ce qu'on nomme une indirection. L'objet utilisé à fin de réaliser une indirection doit être un pointeur sinon l'indirection sera invalide.
L'exemple suivant illustre cette différence:.
short a, *b;
*a = 5; // invalide: a n'est pas un pointeur
b = &a; // valide: b est un pointeur de short, et
// &a est l'adresse d'un short
*b = 5; // valide: b est un pointeur de short. Cette
// opération a pour effet de déponser la valeur 5
// là où pointe b... donc dans a
Petit exemple : le programme suivant utilise un mélange de variables, d'adresses et de pointeurs. Les commentaires à la droite de chaque instruction visent à décrire son impact.
int main()
{
int a, // valeur de a: indéterminée
b= 3; // valeur de b: 3
int *p; // p est un pointeur vers un int
p= &a; // p reçoit l'adresse de a, donc p pointe vers a
*p= 4; // le contenu pointé par p, donc a, reçoit 4
b+= a; // b devient égal à 7!
}
Les pointeurs peuvent être difficiles à visualiser à prime abord, du fait que leur contenu est en fait un lieu, une adresse, plutôt qu'une donnée conventionnelle. Pour s'y retrouver, on a souvent recours à des schémas.
Par exemple, pour le programme précédent, nous pourrions schématiser comme suit. Initialement, les trois variables sont déclarées et sont de même taille: a et b sont tous deux des entiers signés sur sizeof(int) bytes, probablement 32 bits, et p représente l'adresse d'un entier, et par conséquent occupe aussi un espace de 32 bits. Les contenus de a et de p dans ce schéma sont tous deux inconnus (d'où les ?). Le fait que p soit un pointeur vers un int (son type est int*, et non pas int) signifie qu'il ne pourra contenir que l'adresse d'objets reconnus comme des int par le compilateur. |
![]() Remarquez la notation de p, avec une flèche : cela signifie l'endroit pointé par p. |
Une fois que l'opération p= &a sera traitée, nous obtiendrons le schéma suivant. Bien sûr, p n'a pas bougé, mais le contenu de p est maintenant devenu l'adresse de a. Ainsi, p pointe vers a, ou encore p contient l'adresse de a. Les deux formulations sont porteuses du même sens. À partir du moment où p pointe vers a, modifier le contenu pointé par p est équivalent à modifier le contenu de a. Évidemment, le dire est une chose et s'en convaincre en est une autre. Mieux vaut tester cette assertion pour voir si elle tient la route. |
![]() |
À ce titre, la ligne *p= 4; résultera en la modification visible à droite de notre petit schéma, et ce, puisque modifier le contenu pointé par p est exactement la même chose que modifier a. Il y a de réels bénéfices à utiliser des pointeurs, de même que de considérables dangers. |
![]() |
En fait, le mauvais usage de pointeurs a comme résultat certains des bogues les plus malins, les plus intermittents, les plus pernicieux et les plus difficiles à éliminer.
Historiquement, en langage C (tout comme en Java, d'ailleurs, contrairement aux apparence), il n'y avait que des paramètres par valeur. Ceci signifie qu'hormis le recours aux variables globales, il n'y avait que deux manières pour une fonction de communiquer avec le sous-programme l'ayant appelé: par sa valeur de retour ou à travers des variables représentant explicitement des adresses (car une copie d'une adresse mène au même endroit que l'adresse originale).
L'avènement en C++ du passage de paramètres par référence a résolu ce problème philosophique en C++, et c'est pourquoi les pointeurs, quoique toujours importants, sont moins essentiels qu'ils ne l'étaient auparavant.
En langage C, par l'utilisation de pointeurs, une fonction pouvait contourner ce problème et affecter (en quelque sorte) ses paramètres.
En C++, les paramètres des sous-programmes sont normalement passés par valeur. Si le besoin survient d'écrire une procédure permuter() dont le mandat est d'échanger les valeurs de ses deux paramètres, il faut avoir recours à des paramètres passés par référence.
En langage C, l'ancêtre du C++, il n'y avait pas de références. Pour écrire la procédure permuter(), il fallait absolument utiliser des pointeurs. Ça fonctionne, mais il est beaucoup plus lourd sur le plan syntaxique d'utiliser des pointeurs que d'utiliser des références.
À titre d'illustration, l'exemple suivant présente deux versions de la procédure permuter() et d'un programme l'appelant. Celle de gauche utilise des pointeurs, et celle de droite des références. La préférence de la majorité envers les références devrait etre plus compréhensible une fois les deux notations comparées.
Avec pointeurs | Avec références |
---|---|
|
|
Remarquez les éléments en caractères gras :
Pourquoi l'exemple utilisant des pointeurs comme paramètres permet-il à la procédure permuter() d'accomplir correctement sa tâche?
Dans le programme principal, il existe deux variables a et b, toutes deux de type int. Bien entendu, chacune a une adresse. L'adresse de a s'écrit &a et celle de b s'écrit &b. Lors de l'appel de la procédure permuter(), on passe en paramètre &a et &b. Notez la position des & : ailleurs qu'à la déclaration d'une variable ou d'un paramètre, ce symbole représente l'adresse de la variable, pas une référence. |
![]() |
Cela signifie que les paramètres x et y, qui sont tous deux de type int*, prendront respectivement comme valeur une copie de l'adresse de a et une copie de l'adresse de b. Le truc ici est que bien que x soit une copie de l'adresse de a et bien que y soit une copie de l'adresse de b, modifier le contenu pointé par x modifie quand même a et modifier le contenu pointé par y modifie quand même b . Cela dit : quand vous avez accès aux références, tenez-vous en aux références, et évitez les pointeurs. Plus simple, plus propre, moins risqué. |
![]() |
Soit le petit programme proposé à droite. Accéder à un membre de l'enregistrement p0 se fait à travers l'opérateur . (ce qui fait une drôle de phrase, soit). Par exemple, p0.x= 3; et p0.y=p0.x+10; sont deux opérations valides. Accéder à un membre du Point pointé par p1 peut se faire :
|
|
Voici un exemple d'utilisation de pointeurs avec une fonction initialisant un enregistrement.
#include <iostream>
// un Point décrit un point par ses coordonnées x et y
struct Point
{
int x, y;
};
// initialiser() prend l'adresse d'un Point p et affecte
// aux membres x et y de *p de nouvelles valeurs...
void initialiser(Point *p, int x, int y)
{
*p.x = x; // équivalent: p->x = x;
*p.y = y; // équivalent: p->y = y;
}
int main()
{
using namespace std;
Point pt;
initialiser(&pt, 3, 4);
// L'opération suivante affichera (3,4)
cout << "(" << pt.x << "," << pt.y << ")" << endl;
}
Bien entendu, avec des paramètres par référence en C++, on pourrait simplement faire ce qui suit (seules les lignes pertinentes apparaissent ci-après) :
#include <iostream>
struct Point
{
int x, y;
};
void initialiser(Point &p, int x, int y)
{
p.x = x;
p.y = y;
}
int main()
{
using namespace std;
Point pt;
initialiser(pt, 3, 4);
// L'opération suivante affichera (3,4)
cout << "(" << pt.x << "," << pt.y << ")" << endl;
}
Prises une à une, les instructions des deux bouts de code se ressemblent beaucoup. On remarque toutefois que la syntaxe du passage de paramètres par référence est plus transparente : le passage de paramètre par adresse (par pointeur) demande qu'on manipule explicitement un objet intermédiaire (l'adresse du paramètre) plutôt que l'objet lui-même.
Évidemment, en C++, on initialisera un Point dès sa définition à l'aide d'un constructeur, plutôt que de le laisser dans un état indéterminé une fois celui-ci créé puis de lui apposer des valeurs :
#include <iostream>
struct Point
{
int x, y;
Point()
: x{}, y{}
{
}
Point(int x, int y)
: x{x}, y{y}
{
}
}
int main()
{
using namespace std;
Point p0; // {0,0}
Point p1{3,4}; // {3,4}
cout << '(' << p0.x << ',' << p0.y << ")\n"
<< '(' << p1.x << ',' << p1.y << ')' << endl;
}
Le passage d'un pointeur en paramètre demeure un passage de paramètre par valeur. Toutefois, puisque la valeur passée est celle d'une adresse, modifier le contenu de cette adresse permet de modifier effectivement l'objet pointé.
Le passage de paramètre par référence est un espèce de tour de magie du compilateur: en fait, le compilateur passe un pointeur à l'objet référé, tout en nous dissimulant cette manoeuvre. C'est de par ce pointeur caché que notre code parvient à modifier l'objet référé plutôt qu'une simple copie de celui-ci.
Y a-t-il vraiment des dangers à l'utilisation de pointeurs? Oh que si! Si on est en mesure d'écrire là où bon nous semble en mémoire, on est en mesure de modifier à peu près le contenu de tout espace en mémoire, que ce soit volontaire ou non:
Vous trouverez à droite un exemple de manoeuvre « opérationnelle »[8]. À la ligne (0), le programme déclare p qui est un pointeur à un int et qui devrait donc contenir l'adresse d'un entier sur 32 bits. Par la suite, à la ligne (1), on déclare quatre entiers sur huit bits contigus en mémoire, soit de c[0] à c[3] inclusivement. |
|
En mémoire, on aura donc un schéma comme celui à droite :
Il est raisonnable que l'ordre d'apparition corresponde à l'ordre de déclaration (mais il est possible que l'ordre inverse soit celui s'appliquant en réalité; à vous de vérifier ce qu'il en retourne sur votre plateforme de prédilection). On ne connaît présentement la valeur d'aucune de ces variables (d'où les quelques ?) |
![]() |
À la ligne (2), p reçoit l'adresse de c[0]. La conversion explicite de type reinterpret_cast<int *> sert à imposer au compilateur d'accepter que l'adresse de c[0], qui est un char (entier encodé sur un byte) soit affectée à p qui est un int *. Sur le schéma à droite, p et c[0] sont encore consécutifs en mémoire, et p pointe vers c[0]. Pourquoi donc cette opération nécessite-t-elle une conversion explicite de type? À cause du danger bien réel suivant: puisque p pense pointer vers un entier sur 32 bits, modifier le contenu de p signifie modifier les 32 bits débutant à l'adresse pointée par p. |
![]() |
Ainsi, la ligne (3) aura l'impact suivant : affecter la valeur 0x00000000 (donc 0 sur 32 bits) à l'adresse pointée par p, ce qui est ici équivalent à c[0]= 0x00, c[1]= 0x00, c[2]= 0x00et c[3]= 0x00, le tout d'un seul coup. Ici, le code ne devrait pas planter à l'exécution parce qu'il advient (heureusement!) que les 32 bits débutant à &(c[0]) sont les entiers c[0]...c[3][9]. Agir ainsi est une très mauvaise pratique de programmation, et il vous est défendu de faire ce genre de tours de passe-passe dans vos travaux, de même que professionnellement, surtout si vous désirez garder votre emploi! |
![]() |
Le danger peut s'exemplifier comme suit: si nous avions par exemple écrit, à la ligne (2), le code proposé à droite. Remarquez la très légère nuance: ici, plutôt que de faire pointer p au premier élément de c, on a choisi de le faire pointer au deuxième élément. |
|
Alors le schéma se serait transformé en ce que vous voyez à gauche. Et alors, la ligne (3) enverra toujours 0 sur 32 bits débutant à &(c[1])... mais les derniers huit bits de cette séquence, à quoi servent-ils? Mystère! Et c'est là que se trouve le danger: la ligne (3) écrira 0x00 à un endroit qui peut servir à représenter à peu près n'importe quoi. Le risque de planter sérieusement ici est très réel! Lorsqu'on se permet pareilles imprudences, n'importe quoi (vraiment n'importe quoi!) peut flancher sans préavis. Si vous risquez peu de commettre un impair comme celui présenté plus haut, la discussion sur les tableaux (qui suit sous peu) contient, elle, des exemples qui vous feront saisir toute la... réalité du sujet. |
![]() |
Un autre exemple de problème que vous pouvez rencontrer est relatif à l'ordre des bytes dans un mot mémoire de la machine.
Par exemple, prenons le code suivant :
#include <iostream>
#include <iomanip>
int main()
{
using namespace std;
char T[4]= { 0x01, 0x02, 0x03, 0x04 };
short s;
int i;
char* p;
// copier deux bytes de T dans s
p= reinterpret_cast<char*>(&s); // mérite un commentaire!
*p= T[0];
*(p+1)= T[1];
cout << hex << s1 << dec << endl; // (0)
// copier quatre bytes de T dans i
p= reinterpret_cast<char*>(&i); // mérite un commentaire!
*p= T[0];
*(p+1)= T[1];
*(p+2)= T[2];
*(p+3)= T[3];
cout << hex << i << dec << endl; // (1)
}
La ligne (0) affichera probablement 201 à l'écran (pour 0x0201) plutôt que 102 (pour 0x0102), à cause de considérations propres à la représentation interne sur la machine que nous utilisons : l'ordre des bytes dans un mot n'est pas nécessairement ce qu'il semble être à première vue!
De même, la ligne (1) affichera probablement 4030201 plutôt que 1020304. Essayez-le!
C'est pourquoi il faut être extrêmement prudent(e) lorsqu'on effectue pareille manoeuvre. La représentation interne des nombres entiers pour un ordinateur peut changer d'un processeur à l'autre; si nous essayons de jouer plus bas de notre propre chef, nous devons agir avec circonspection et vérifier nos programmes pour s'assurer que nous n'avons pas commis de faute!
Un tableau représente une suite d'objets de même type disposés de façon consécutive en mémoire. Débutons notre discussion par un petit rappel.
Prenons la déclaration du tableau tab proposée à droite. On y déclare 20 objets de type int consécutifs en mémoire, le premier débutant là où se trouve effectivement tab. On accède au premier de ces 20 objets par tab[0], au second par tab[1] et ainsi de suite jusqu'à tab[19]. |
|
On peut donc choisir de calculer la somme de tous les éléments de tab par une boucle semblable à la suivante.
int main()
{
const int N = 20;
int tab[N];
//
// Code servant à remplir tab; omis par souci de simplicité
//
long somme = {};
for (int i= 0; i < N; ++i)
{
somme += tab[i];
}
// somme contiendra la somme de T[0] à T[N-1] inclusivement
}
Vous découvrirez sans doute mille et une façons d'appliquer cet outil à vos tâches de tous les jours. Dans ce document, nous allons jeter un regard sur ce que signifie et représente un tableau à l'interne.
Le type de chaque élément d'un tableau comme tab, plus haut, est celui déclaré pour le tableau en entier. En l'occurrence pour un tableau déclaré comme int tab[N];, le type de tab[0] est int, tout comme l'est le type de tab[1], celui de tab[2] et ainsi de suite.
Mais qu'en est-il du type de tab lui-même? On connaît le type de chaque entrée du tableau, mais quel est le type du tableau?
Un tableau est en fait un pointeur vers son premier élément. Ainsi, le tableau tab est précisément (à une subtilité près) équivalent en terme de représentation à tab+0 ou à &(tab[0]).
Concrètement, donc, un tableau comme tab se représente en mémoire comme suit. Si tab est un tableau de short, donc d'entiers sur sizeof(short) bytes :
Est-ce utile de connaître tout cela? En théorie, on devrait faire semblant qu'on n'en sait rien, mais dans les faits, ce savoir sert beaucoup, particulièrement en entreprise.
|
![]() |
Avec ce savoir, il devient possible par exemple d'écrire une fonction qui calcule la somme des éléments d'un tableau, comme celle qui suit :
long somme_elements(const int *tab, size_t n)
{
long somme= {};
for(size_t i = 0; i < n; ++i)
somme+= tab[i];
return somme;
}
...ou encore (ce qui est précisément identique du fait qu'un tableau n'est, au fond, qu'un pointeur) :
long somme_elements(const int tab[], size_t n)
{
long somme= {};
for (size_t i = 0; i < n; ++i)
somme+= tab[i];
return somme;
}
...ou même (aussi identique du point de vue fonctionnement mais un peu plus obscur pour les non initié(e)s... Cette notation est extrêmement puissante et très importante, pour ne pas dire fondamentale – elle mène aux itérateurs – mais nous n'en avons pas besoin pour le moment) :
long somme_elements(const int *debut, size_t n)
{
long somme= {};
const int *fin = debut + n;
for (; debut != fin; ++debut)
somme+= *debut;
return somme;
}
Ainsi, en offrant un pointeur à un entier, dans les faits un tableau d'entiers donc un pointeur à son premier élément, et une taille, soit le nombre d'éléments du tableau, il est donc possible d'écrire une fonction qui calcule la somme des éléments d'un tableau d'entiers de taille arbitraire.
Il y a une nuance à apporter quant aux itérateurs, mais ce sera pour un autre jour.
Il est essentiel de passer la taille du tableau en paramètre, pour éviter des accidents: il n'y a rien dans le tableau qui en dise la taille réelle.
Ainsi, si le code ne s'assure pas que la fonction connaisse la taille réelle allouée au tableau, par exemple avec un paramètre la spécifiant, la fonction en tant que telle pourrait très bien passer tout droit et calculer dans sa somme le contenu des « éléments » se trouvant à la suite du tableau dans la mémoire.
L'arithmétique sur les pointeurs est permise en C et en C++ (alors qu'elle est interdite dans la plupart des langages de programmation dits de haut niveau) du fait que le programmeur est supposé savoir, dans ces langages, qu'une adresse est en fait un entier codé sur un mot mémoire.
Dans la mesure du possible, pour plusieurs raisons, on cherche à oublier cet état de fait, comme on cherche à oublier toutes considérations de représentations internes... mais vous ne pouvez quand même pas partir avec un diplôme en informatique sans en être conscient(e)s vous-mêmes.
On peut initialiser un tableau dès sa déclaration, de la manière suivante (à droite). Le tableau tab[] ici présenté aura donc les valeurs 4, 7 et -23 aux indices 0, 1 et 2 respectivement (donc tab[1]==7, pour ne prendre qu'un exemple parmi tant d'autres). Remarquez que ce tableau peut contenir au plus N éléments, et que le tableau est initialisé avec précisément N valeurs. En fait, on aurait pu en toute légalité utiliser moins de valeurs à l'initialisation (initialiser seulement une partie des entrées du tableau), mais on n'aurait pas été en droit d'en utiliser plus (car cela aurait causé un débordement du tableau détectable à la compilation). |
|
En langage C, les éléments non initialisés d'un tableau ont des valeurs aléatoires (du moins, ils n'ont aucune valeur sur laquelle on puisse compter). En C++, si au moins un élément d'un tableau est initialisé à la déclaration, alors tous les éléments qui n'auront pas été initialisés explicitement seront initialisés à zéro. Si aucun élément n'est initialisé à la déclaration dans un tableau C++, alors le comportement sera identique à celui du langage C.
Exemple (éléments non initialisés) | Exemple (débordement) |
---|---|
|
|
On utilise seulement deux valeurs pour initialiser un tableau dont la capacité est de trois éléments. Les entrées explicitement initialisées sont l'entrée 0 et l'entrée 1. Les valeurs initiales d'un tableau sont attribuées selon l'ordre dans lequel ses entrées apparaissent en mémoire. Tel qu'indiqué ci-dessus, le comportement pour l'initialisation de l'élément restant dépendra du langage (C et C++ diffèrent en ce sens). |
On essaie d'utiliser quatre valeurs pour initialiser un tableau dont la capacité n'est que de trois éléments. Le compilateur refusera de générer le code pour ce programme, qu'il peut reconnaître comme visiblement erroné. |
L'appel de la fonction somme_elements(), plus haut, pourrait donc se faire comme suit:
#include <iostream>
// prototype de somme_elements()
int main()
{
using namespace std;
const size_t N = 7;
int tab[N]=
{
-8, 15, 3, 244, 78571, 0, -1
};
// code qui modifie les entrées de tab[]
auto somme = somme_elements(tab, N);
cout << somme << endl; // imprimera "78824"
}
Ce programme fonctionne parfaitement car (a) le tableau d'entiers tab est de type int *; (b) le tableau en question est correctement initialisé; et (c) la taille N passée en paramètre à la fonction est correcte.
En pratique, il est souvent plus simple de laisser le compilateur évaluer le nombre d'éléments d'un tableau automatique à partir des valeurs utilisées pour l'initialiser. Ainsi, ne vous surprenez pas de voir, dans du code de production, l'écriture suivante :
int main()
{
int tab[] = { 2, 3, 5, 7, 11 };
const size_t N = sizeof(tab)/sizeof(tab[0]);
// sizeof(tab) == 5 * sizeof(int), donc
// 5 * sizeof(int) / sizeof(int) == 5
}
...où N est évalué à partir de la taille totale du tableau tab, en bytes, et de la taille d'un de ses éléments typiquement le tout premier du lot, car c'est le seul qui sera nécessairement présent.
Puisqu'un tableau est fait d'éléments du même type, tous contigus en mémoire, sizeof(tab)/sizeof(tab[0]) sera précisément le nombre N d'éléments utilisés pour initialiser tab, et ce indépendamment du type des éléments.
[1] Bien que le code assembleur généré par Visual Studio utilise word pour dénoter un espace de 16 bits et dword pour un autre de 32 bits, le processeur Pentium utilise des registres 32 bits, et emploie bel et bien des mots mémoire de 32 bits.
[2] ... ce qui est un peu lourd à expliquer et tend à être particulier à chaque machine.
[3] C'est pourquoi il est fort important d'initialiser vos variables, mes snorros!
[4] ... là où il en est dans la génération du code objet lorsqu'il rencontre la déclaration de la variable.
[5] Le code exact généré dépendra du compilateur, et des options utilisées lors de la compilation. Nous nous limiterons à un modèle simplifié, pour fins pédagogiques, car il s'agit là d'un domaine très vaste, où la compétition entre les compagnies est féroce.
[6] ... qui contient, rappelons-le, l'adresse de la prochaine instruction à traiter.
[7] Prudence: le code assembleur change d'une plateforme à l'autre, d'un compilateur à l'autre, et il faut donc se montrer tolérant si le professeur ne donne pas à 100% le même résultat que le compilateur VC dernier cri... quoique les deux doivent être relativement près l'un de l'autre dans ce cas.
[8] ... mais à éviter. Les exemples sont là pour démontrer le concept, pas pour vous inciter à mal travailler.
[9] ... que nous y avons judicieusement placées, « vlimeux » que nous sommes.