C'est quoi ?
Tout d'abord, nous allons devoir parler de l'utilité de ces pointeurs. Et quelques rappels sont nécessaires.
Comme je l'ai dit dans le chapitre sur les variables, toutes les
informations manipulées par un ordinateur sont stockées dans un
composant électronique particulier, présent das notre ordinateur : la
mémoire.
Enfin, je dis la mémoire, mais en fait il y en a plusieurs. Tout comme
un être humain possède plusieurs mémoires (mémoire à court terme,
mémoire à long terme, etc) qui lui servent à mémoriser plein
d'informations : on trouve ainsi les registres, la mémoire RAM, et le
disque dur.
Pour manipuler des données, notre ordinateur doit impérativement savoir
où elles sont dans la mémoire de l'ordinateur. Nos ordinateurs sont
conçus ainsi. Leur manipulation nécessite donc quelque chose qui permet
de connaitre la localisation des données dans la mémoire de
l'ordinateur. On doit donc fournir ce qu'on appelle une
référence,
qui permet d’identifier la localisation de notre donnée dans la mémoire
: dans quel registre elle est, à quel endroit sur le disque dur, dans
quelle portion de la mémoire RAM, etc. Ainsi, toute donnée est
identifiée dans notre ordinateur par une référence, qui permet d’accéder
à notre donnée plus ou moins directement.
Données complexesNos variables simples, comme des int, float, double, ou char
individuels sont souvent placées dans des registres. La gestion de
ceux-ci étant directement gérée par le compilateur, on ne doit pas
manipuler de références vers ces registres : les noms de variables
suffisent pour le programmeur.
Mais pour les données plus grosses, c'est une autre histoire. Eh oui :
en C, il est possible d'utiliser des données plus grosses que des
simples variables. Il est en effet possible de créer des données plus
complexes à partir de variables simples comme des int, float, double, ou char.
Toutes ces données sont trop grosses pour être stockées dans des
registres, et elles sont donc placées en mémoire RAM. Chacune de ces
données complexes sera ainsi stockée dans un morceau de mémoire, un bloc
de bytes, qui commencera à une certaine adresse.
Ces données sont composées de variables simples, qu'on regroupe ensemble
dans la mémoire d'une certaine façon. Ainsi, on peut voir ces données
plus complexes comme des regroupements de int, float, double, ou char.
Et on ne peut pas manipuler de grosses données complexes directement :
on est obligé de manipuler les données simples consistants nos données
complexes. Traiter une information complexe sera donc fait à partir de
manipulations de base sur des int, float, double, ou char.
Création de données complexesPour manipuler une donnée complexe, on doit manipuler des données
simples individuellement. Ainsi, pour créer certaines données complexes,
on aura obligatoirement besoin de connaitre la position en mémoire des
données simples qui constituent notre données complexe, afin de pouvoir
les manipuler. Sans cela, on ne sait même pas où sont placées ces
données et les manipuler est juste impossible.
Dans certains cas, nos données sont regroupées dans un seul gros bloc de
mémoire. Dans ce cas, on peut déterminer la position de chaque donnée
simple à partir de la position à laquelle commence notre donnée complexe
en mémoire : au lieu de retenir toutes les adresses de chaque donnée,
on a simplement à de souvenir de la position de la première donnée et
faire quelques opérations dessus. Mais il faut se souvenir de la
position en mémoire RAM à laquelle commence notre donnée.
Autre exemple : les données complexes pour lesquelles les données ne
sont pas regroupées dans un seul bloc de mémoire, mais sont dispersées
un peu partout. De telles données existent et permettent d'enlever ou de
rajouter des données simplement, de façon efficace. Sans connaitre la
position de chaque donnée, vous êtes morts ! Créer de telles structures
de données nécessite de connaitre la position en mémoire de nos données
individuelles. Et c'est au programmeur de gérer tout cela.
Allocation dynamiqueEnfin, il faut savoir que nos données complexes ne sont pas éternelles.
En effet, nos données complexes prennent de la mémoire RAM, qui est en
quantité limitée. Réserver de façon permanente de la mémoire pour chaque
donnée dont on aurait besoin reviendrait à se tirer une balle dans le
pied.
Pour limiter la casse, il est possible de réserver une portion
inutilisée de la mémoire pour stocker temporairement des données
complexes. Nos données complexes peuvent ainsi, sous certaines
conditions, se réserver de la mémoire temporairement. Quand un programme
a besoin d'un peu plus de mémoire pour stocker une donnée complexe, il
peut ainsi réserver une partie vide de la mémoire, et se l'approprier
pour stocker une donnée. Quand on n'a plus besoin de cette mémoire, on
la libère, et elle sera réutilisable à volonté. C'est ce qu'on appelle
l'allocation dynamique.
Le seul problème, c'est que la localisation de la portion de mémoire
change à chaque réservation. On doit donc se souvenir de l'endroit où
l'on vient de stocker notre donnée en mémoire. Sans cela, on ne
retrouvera pas notre donnée dans la mémoire et la manipuler sera
impossible. Et c'est encore une fois au programmeur de gérer cela, du
moins en partie.
Passage de données complexes en argument de fonctions Autre exemple : si je veux qu'une fonction manipule une donnée complexe,
je peux passer cette donnée en argument de ma fonction. L'intégralité
de ma donnée complexe sera alors recopiée, et ma fonction manipulera la
copie : cela prend du temps. De plus, l'original n'aura pas été mis à
jour ! Et dans certains cas, cela pose problème.
Autre solution : demander à ma fonction de modifier directement
l'original et non la copie. Je vous propose d'illustrer cela par un
exemple. Imaginons que l'un de vos amis découvre un site génial et
décide de vous le montrer. Que fait-il ?
- Il télécharge tous les fichiers du site sur son ordinateur et me les
donne. J'ai au final un très gros dossier qui contient plein de
fichiers, et si je décide de modifier quelque chose (de poster un
message sur un forum par exemple), la modification ne touchera que la
copie et non l'original.
- Il copie le lien du site et me l'envoie. J'ai donc accès au site
original, et si je le modifie, les modifications seront visibles de tout
le monde et perdureront.
Cette deuxième solution est de loin la plus rapide pour les données
complexes. C'est ainsi : quand on veut que notre programme manipule des
données, ces données doivent souvent être envoyées à diverses fonctions,
et la façon dont on les passe change radicalement les performances. Et
c'est encore au programmeur de gérer cela : il doit indiquer où est
l'original dans la mémoire.
ConclusionBref, c'est (entre autres) à cela que servent les pointeurs : ils
servent à donner l'emplacement en mémoire RAM d'une donnée précise pour
qu'on puisse la manipuler. Sans cela, il serait impossible de créer ou
de manipuler des données complexes crée à partir de données plus
simples. Ce qu'il nous faut, c'est donc réussir à stocker la
localisation en mémoire RAM à laquelle commence une donnée, et la
manipuler comme bon nous semble.
La RAMAfin d'identifier la position d'une donnée en mémoire vous n'avez pas
trop le choix : vous devez savoir où se trouve votre donnée dans la
mémoire de l'ordinateur. Et pour retrouver notre donnée en RAM, rien de
plus simple.
Dans notre RAM, les données sont découpées en "paquets" contenant une quantité fixe de bits : des "
cases mémoires", aussi appelées bytes. Généralement, nos mémoires utilisent un byte de 8 bits, aussi appelé
octet.
Avec un octet, on peut stocker 256 informations différentes. Par
exemple, on peut stocker 256 nombres différents. On peut stocker les
lettres de l'alphabet, ainsi que les symboles alphanumériques. On peut
aussi stocker tous les nombres de 0 à 255, ou de -128 à 127, tout dépend
de comment on s'y prend.
Pour stocker plus d’informations (par exemple les nombres de -1024 à
1023), on peut utiliser plusieurs octets, et répartir nos informations
dedans. Nos données peuvent prendre un ou plusieurs octets qui se
suivent en mémoire, sans que cela pose problème : nos mémoires et nos
processeurs sont conçus pour gérer ce genre de situations facilement. En
effet, nos processeurs peuvent parfaitement aller lire 1, 2, 3, 4, etc.
octets consécutifs d'un seul coup sans problèmes, et les manipuler en
une seule fois. Mais cela ne marche que pour des données simples.
Adresse mémoireChacun de ces octets se voit attribuer un nombre unique,
l'adresse,
qui va permettre de la sélectionner et de l'identifier celle-ci parmi
toutes les autres. Il faut imaginer la mémoire RAM de l'ordinateur comme
une immense armoire, qui contiendrait beaucoup de tiroirs (les cases
mémoires) pouvant chacun contenir un octet. Chaque tiroir se voit
attribuer un numéro pour le reconnaitre parmi tous les autres. On pourra
ainsi dire : je veux le contenu du tiroir numéro 27 ! Pour la mémoire
c'est pareil. Chaque case mémoire a un numéro : son adresse.
AdresseContenu mémoire
|
0 | 11101010 01011010 |
1 | 01111111 01110010 |
2 | 00000000 01111100 |
3 | 01010101 0000000 |
4 | 10101010 00001111 |
5 | 00000000 11000011 |
En fait, on peut comparer une adresse à un numéro de téléphone (ou à une
adresse d'appartement) : chacun de vos correspondants a un numéro de
téléphone et vous savez que pour appeler telle personne, vous devez
composer tel numéro. Ben les adresses mémoires, c'est pareil !
Exemple : on demande à notre mémoire de sélectionner la case mémoire d'adresse 1002 et on récupère son contenu (ici, 17).
PointeursNous y voilà : ce qu'il nous faut, c'est donc réussir à stocker
l'adresse mémoire à laquelle commence une donnée, et la manipuler comme
bon nous semble. Pour cela, on a inventé les
pointeurs : ce sont des variables dont le contenu est une adresse mémoire.
Exemple, avec une variable a qui est un pointeur sur une variable b.
C'est aussi simple que cela. Leur utilité ne semble pas évidente au
premier abord, mais sachez que cela deviendra plus clair après quelques
exemples. De plus, nous utiliserons beaucoup les notions vues dans ce
chapitre une fois qu'on parlera des tableaux et des structures de
données. Cela ne se voit pas au premier abord, mais les pointeurs sont
importants dans le langage C.
Utilisation
Avant de rentrer dans le vif du
sujet, il faut encore faire quelques remarques sur nos fameux pointeurs.
Cette fois-ci, on va rentrer vraiment dans la pratique : finie la
théorie, on va parler de choses spécifiques au langage C.
En C, les pointeurs sont typés : les pointeurs qui stockent l'adresse d'un int ou d'un char seront différents. C'est comme ça, les pointeurs vont stocker les adresses mémoires d'une donnée simple, qui peut être un int, un char, un float ou un double. Le type du pointeur sert à préciser si l'adresse contenue dans ce pointeur est l'adresse d'un int, d'un char, d'un float, etc.
Vous vous demandez surement à quoi peut bien servir cette bizarrerie.
Tout ce que je peux dire pour le moment, c'est que c'est dû au fait que
des données de type différent n'occuperont pas la même quantité de
mémoire : suivant la machine, un int peut ne pas avoir la même taille qu'un short,
par exemple. Pour éviter les ennuis lors des manipulations de
pointeurs, les pointeurs sont donc typés. Vous verrez quand on abordera
l'arithmétique des pointeurs : on devra calculer des adresses mémoires,
et la taille des types jouera beaucoup lors de ces calculs. Vous
comprendrez alors l'utilité du typage des pointeurs.
DéclarationPour déclarer un pointeur, il suffit de lui donner un nom, et de
préciser son type, c'est-à-dire le type de la donnée dont on stocke
l'adresse dans le pointeur. La syntaxe de la déclaration est celle-ci :
Code : C -
Par exemple, si je veux créer un pointeur sur un int (c'est-à-dire un pointeur pouvant stocker l'adresse d'une variable de type int) et que je veux le nommer ptr, je devrais écrire ceci :
Code : C -
Un pointeur particulierNous venons de dire qu'un pointeur devait forcément pointer sur un objet
d'un type donné. A priori, il semble impossible de faire un pointeur
qui pourrait pointer sur n'importe quel type d'objet sans problème.
Pourtant, les concepteurs de la norme C89 ont pensé à nous : ils ont
introduit un pointeur un peu spécial appelé
pointeur universel : void *.
Il est automatiquement converti en pointeur sur le type de l'objet
pointé : il peut donc pointer sur un objet de n'importe quel type.
Code : C -
1 2 3 | int i = 5; int * ptr = &i; void * uni = ptr;
|
Dans cet exemple, notre premier pointeur est de type int*, donc le second est automatiquement converti en pointeur sur int. A noter que cela marche également dans l'autre sens : un pointeur sur un type T peut être converti en pointeur sur void.
Pour la touche culturelle, sachez qu'avant l'apparition de la norme C89
le pointeur universel n'existait pas. On utilisait à la place un
pointeur sur char.
RéférencementLe référencement est une opération qui permet de récupérer l'adresse d'un objet. Pour se faire, il suffit de placer l'opérateur & devant la variable dont on souhaite récupérer l'adresse. Pour l'afficher, on utilise printf avec un nouvel indicateur de conversion : %p qui affiche le résultat en hexadécimal. Il y a cependant une petite contrainte : il faut convertir le pointeur en void* (ceci n'est valable que pour printf, pas pour les autres fonctions).
Ce qui donne ceci :
Code : C -
1 2 | int ma_variable; printf("Adresse de ma_variable : %p\n", (void *)&ma_variable);
|
Code : Console -
Adresse de ma_variable : 0x7fff9ee178ac |
Cette valeur change à chaque exécution, ne vous inquiétez pas si vous n'obtenez pas le même résultat que moi.
Initialisation et pointeur nullUn pointeur, comme une variable, ne possède pas de valeur par défaut. Il
pointe donc sur n'importe quoi, n'importe où en mémoire. Il est donc
important de l'initialiser pour éviter d'éventuels problèmes. Pour se
faire, il y a deux possibilités : soit l'initialiser à une valeur nulle,
soit l'initialiser avec une adresse.
Initialisation avec une adresse validePour commencer, on peut initialiser un pointeur avec une adresse, que ce
soit celle d'une variable ou le retour d'une fonction renvoyant un
pointeur.
Code : C -
1 2 3 | int ma_variable = 10; int *mon_pointeur; mon_pointeur = &ma_variable;
|
Ou même ainsi :
Code : C -
1 2 | int ma_variable = 10; int *mon_pointeur = &ma_variable;
|
Pointeur NULLMais il arrive qu'on doive déclarer un pointeur sans savoir quelle
adresse mettre dedans, cette adresse étant disponible plus tard, plus
loin dans le code. Dans ce cas, on peut donner une valeur particulière
au pointeur pour faire en sorte qu'il ne pointe nulle part et qu'il soit
considéré comme invalide. Cette valeur particulière s'appelle NULL.
Code : C -
1 2 | /* maintenant ptr est un pointeur invalide */ int * ptr = NULL;
|
DéréférencementS'il est possible de récupérer l'adresse d'un objet grâce au
référencement, il est également possible d'accéder et de modifier cet
objet grâce au
déréférencement. Sans cette possibilité, les pointeurs n'auraient aucune utilité.
Cette opération se fait avec l’opérateur *. Avec la syntaxe *mon_pointeur, nous avons accès à la donnée contenue à l'adresse de mon_pointeur
aussi bien en lecture qu'en écriture. C'est le cas dans le premier
exemple ci-dessous où l'on affecte à une variable la valeur du contenu
du pointeur, c'est-à-dire la valeur de ma_variable.
Code : C -
1 2 3 4 5 6 7 | int a = 0; int ma_variable = 10; int *mon_pointeur = &ma_variable;
a = *mon_pointeur ;
printf("Valeur contenue a l'adresse '%p' : %d\n", (void *)mon_pointeur, a);
|
Code : Console -
Valeur contenue a l'adresse '0x7ffa2ee591a7' : 10 |
Dans le deuxième exemple, on modifie la valeur de l'objet pointé par mon_pointeur, a qu'indirectement on modifie ma_variable.
Code : C -
1 2 3 4 5 6 7 8 | int ma_variable = 10; int *mon_pointeur = &ma_variable;
printf("Valeur contenue à l'adresse '%p' : %d\n", (void *)mon_pointeur, *mon_pointeur);
*mon_pointeur = 20;
printf("Valeur contenue à l'adresse '%p' : %d\n", (void *)mon_pointeur, *mon_pointeur);
|
Code : Console -
Valeur contenue a l'adresse '0028FF18' : 10 Valeur contenue a l'adresse '0028FF18' : 20 |
Des pointeurs comme arguments dans des fonctionsIl existe deux manières d'utiliser les pointeurs avec les fonctions. Voici la première :
Code : C -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #include
void ma_fonction(int *mon_pointeur) { *mon_pointeur = 20; }
int main(void) { int ma_variable = 10; ma_fonction(&ma_variable);
printf("Valeur de ma_variable : %d\n", ma_variable);
return 0; }
|
Là, il n'y a rien de très difficile : on envoie l'adresse de ma_variable à ma_fonction puis celle-ci modifie la valeur de ma_variable directement en mémoire par l'intermédiaire de mon_pointeur.
Quel est l'intérêt d'utiliser les pointeurs à la place de return
? On peut par exemple modifier la valeur de plusieurs variables dans
une fonction au lieu de ne pouvoir en modifier qu'une qu'on retourne.
Passons maintenant à la deuxième technique pour envoyer un ou plusieurs pointeurs à une fonction :
Code : C -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #include
void ma_fonction(int *mon_pointeur) { *mon_pointeur = 20; }
int main(void) { int ma_variable = 10; int *mon_pointeur = &ma_variable;
ma_fonction(mon_pointeur);
printf("Valeur de ma_variable : %d\n", ma_variable); printf("Valeur contenue a l'adresse de mon_pointeur : %d\n", *mon_pointeur);
return 0; }
|
Sans exécuter ce code, essayez de deviner quel sera le résultat de ces deux printf. Trouvé ? Si ce n'est pas le cas, relisez entièrement et très attentivement ce chapitre. Voici le résultat attendu :
Exercice
Pour le moment, vous vous dites
surement que les pointeurs ne sont pas forcément nécessaires, et qu'on
peut surement s'en passer. De nombreux débutants en C se disent cela, et
c'est une des raisons qui fait que certains ne comprennent pas
facilement les pointeurs. À ce stade du tutoriel, il sera difficile de
vous montrer leur utilité, même si la suite du tutoriel vous prouvera le
contraire.
Néanmoins, cet exercice va vous montrer que les pointeurs peuvent
parfois être utiles. En effet, nos pointeurs sont utiles en argument des
fonctions. C'est ainsi : dans une fonction, il est impossible de
modifier les arguments : on ne fait que manipuler une copie de ceux-ci !
Si jamais notre fonction doit manipuler un ou plusieurs des arguments
pour le modifier, on ne peut pas le faire avec une fonction. Du moins,
pas sans nos pointeurs.
ÉnoncésPour mieux comprendre, on va vous donner un exemple pratique. Vous aller
devoir programmer une fonction nommé swap, qui va échanger le contenu
de deux variables. Cette fonction ne semble pas bien méchante, mais
sachez qu'elle est assez utilisée, mine de rien. Par exemple, si vous
voulez trier un gros paquet de données, vous aurez absolument besoin de
cette fonction. Mais je n'en dit pas plus. Quoiqu'il en soit, on va
supposer que dans notre exemple, nos deux variables sont des int.
Bonne chance !
CorrectionPour commencer, le prototype de notre fonction est :
Code : C -
1 | void swap(int *valeur_1, int *valeur_2);
|
C'est ainsi : pour modifier les variables directement, au lieu de
manipuler une copie, on va simplement envoyer des pointeurs sur ces
variables, qu'on déréférencera dans notre fonction, pour modifier les
valeurs originelles.
Ainsi, on obtient le code suivant :
Code : C -
1 2 3 4 5 6 7 8 | void swap(int *valeur_1, int *valeur_2) { int temporaire;
temporaire = *valeur_1; /* La variable "temporaire" prend la valeur de "valeur_1" */ *valeur_1 = *valeur_2; /* Le pointeur "valeur_1" prend la valeur de "valeur_2" */ *valeur_2 = temporaire; /* Le pointeur "valeur_2" prend la valeur de "temporaire" */ }
|