Les pointeurs

Dans ce cours, nous allons aborder la notion de pointeur. C’est une notion qui est connue pour poser des difficultés aux étudiants et qui augmente fortement le risque d’erreurs de programmation (et donc de plantages des programmes réalisés) lorsqu’ils sont mal maîtrisés. De plus, un certain nombre de langages de programmation semblent s’en abstraire en ne proposant pas leur manipulation (du moins pas de manière directe). Pourquoi les étudier alors ? Tout simplement parce que leur fonctionnement reflète (en partie du moins) la façon dont est conçue la mémoire de l’ordinateur et comment les systèmes d’exploitation gèrent celle-ci. Mieux encore, les pointeurs permettent de manipuler de manière précise la mémoire, ce qui permet la réalisation d’applications pointues, rapides et économiques. C’est un cours crucial qui nécessite quelques prérequis, surtout des bases de la programmation en C :

Les variables et la mémoire

Comme nous l’avons déjà vu (dans le cours sur les variables), pour chaque variable que vous souhaitez utiliser dans votre programme, vous devez explicitement lui donner un nom. Cette étape se nomme la déclaration des variables. les variables sont placées dans la mémoire de l’ordinateur et nous pouvons les manipuler en lisant ou en modifiant leur valeur en utilisant directement le nom que nous leur avons donné. Cependant, il faut être conscient que donner un nom facile à retenir à une variable n’est utile que pour un être humain, un développeur, pour lui faciliter son travail, rendre son code lisible et donc diffusable auprès de ses collègues. En effet, que la variable se nomme toto ou sdfsfs212121 ne change pas la complexité des opérations que l’on demande à l’ordinateur, ni le but du programme.

Lors de la déclaration d’une variable, un espace en mémoire est réservé pour cette variable et le nom de la variable est lié à cet emplacement mémoire. Lorsque nous allons manipuler une variable dans notre programme, le compilateur va, en fait, faire le lien avec son emplacement en mémoire et manipuler la valeur située à cette adresse.

La mémoire de l’ordinateur est adressée octet par octet. Cela signifie que chaque octet de la mémoire possède une adresse différente. Dans l’exemple suivant, chaque case représente un octet de la mémoire. L’adresse d’une case se trouvant juste au dessus de ladite case :

memoirevideexemple

Une variable peut nécessiter plus d’un octet pour être stockée. C’est le cas, par exemple, des entiers (int) qui sont enregistrés sur 4 octets sur un grand nombre de machines actuelles, des entiers courts qui sont représentés sur 2 octets et des caractères sur 1 octet. L’adresse en mémoire d’une variable correspond à l’adresse du premier octet où est enregistrée cette variable.

Attention !
Ces tailles sont données à titre d’exemple et ne constituent pas une vérité absolue. Le nombre d’octets nécessaires pour représenter un type de données dépends de plusieurs facteurs, comme le processeur, le système d’exploitation et l’implémentation de votre compilateurs. Pour plus de détails, n’hésitez pas à relire le cours sur les types de base du langage C.

Exemple : On note toto un entier dont la valeur est 65000, c un caractère dont la valeur est ‘b’ et nb un entier dont la valeur est 200. Voici une représentation de ces trois variables en mémoire. L’adresse de la variable toto sera 0028FF40, celle de c sera 0028FF46 et celle de nb sera 0028FF49.

memoire3variablesexemple

Nous savons déjà comment déclarer ces trois variables, ainsi que leur affecter une valeur :

Etant donné que par définition une variable possède obligatoirement une adresse mémoire et que les pointeurs permettent de manipuler des adresses mémoires, on se doute que pouvoir connaitre l’adresse mémoire où est enregistrée une variable donnée peut être intéressant, mais comment faire ? En utilisant l’opérateur ‘ & ‘ :

L'opérateur unaire &
L’opérateur unaire & placé devant le nom d’une variable retourne l’adresse de cette variable. Pour afficher l’adresse retournée, on doit utiliser le format %p.

Il est d’usage d’afficher les adresses mémoire non pas en base 10 (c’est à dire des nombres composés avec les chiffres 0,1,2,3,4,5,6,7,8 et 9 ) mais en hexadécimal ( c’est à dire en base 16, avec des nombres composés avec les chiffres 0,1,2,3,4,5,6,7,8,9,A,B,C,D,E et F ). La fonction printf possède un formatage particulier, %p, pour cet affichage (facile à retenir « %p, avec p comme pointeur »).

Exemple d’utilisation :

Exemple 1

Ce qui nous donne le résultat suivant :

exempleaffichagepointeur

Valeur ≠ adresse
Il faut bien distinguer la valeur d’une variable, c’est à dire ce que l’on enregistre dans la variable, de l’adresse de la variable, c’est à dire de l’endroit où cette valeur est enregistrée.

Si vous avez lu le cours sur les types de base du langage C, et que tout comme moi vous constatez que la taille d’un int est la même que celle d’un pointeur, vous pouvez également afficher une adresse en utilisant le formatage des entiers proposé par printf, le %d (mais préférez le %p) :

Maintenant que nous savons ce que signifie un ‘&’ devant un nom de variable, faisons un petit retour sur l’une des premières fonctions que nous avons rencontré, la fonction scanf :

La fonction scanf

He oui ! La fonction scanf enregistre la donnée saisie directement à l’adresse mémoire de la variable passée en argument. Vous comprenez mieux ? Nous verrons plus en détail le rôle du formatage des données (le %d dans cet exemple).

Les pointeurs

Si vous avez bien compris comment sont enregistrées les variables en mémoire, comment les déclarer, les manipuler grâce à l’opérateur d’affectation ‘=’ et comment récupérer l’adresse d’une variable grâce à l’opérateur ‘&’, nous pouvons entrer dans le vif du sujet :

Pointeur
Un pointeur est une variable dont la valeur est égale à une adresse mémoire. En particulier, cette adresse peut correspondre à l’adresse d’une autre variable. On dit alors que le pointeur « pointe » vers cette variable.

La déclaration d’un pointeur se fait de la manière suivante : type * nom_du_pointeur; avec type le type de la variable vers laquelle on pointe.

Voici un exemple d’utilisation. Nous allons déclarer un pointeur ainsi qu’une variable initialisée lors de la déclaration et afficher l’adresse ainsi que la valeur de la variable. Nous allons faire pointer notre pointeur sur la variable et afficher l’adresse de notre pointeur ainsi que sa valeur.

Exemple 2

Ce qui nous donne le résultat suivant :

exempleaffichagepointeur3

Quelques explications, ligne par ligne :

  • ligne 6 : on déclare p, un pointeur d’entier.
  • ligne 7 : on déclare une variable entière x, que l’on initialise à 1.
  • ligne 8 : on affiche la valeur contenue dans la variable x.
  • ligne 9 : on affiche l’adresse de la variable x.
  • ligne 10 : on fait pointer p vers x, en affectant l’adresse de x à p. Concrètement, la valeur contenue dans la variable p sera l’adresse de la variable x.
  • ligne 11 : on affiche l’adresse de la variable p. En effet, p est une variable (un pointeur certes, mais une variable tout de même). Comme toute variable, elle est stockée en mémoire à une certaine adresse, celle que l’on affiche donc.
  • ligne 12 : on affiche le contenu de la variable p, c’est à dire l’adresse de la variable x.

Un petit schéma, très simple, de ce qui se passe dans la mémoire :

memoirepointeurexemple

Nous savons donc maintenant déclarer un pointeur et récupérer l’adresse d’une variable. On souhaite également pouvoir lire et modifier un contenu situé à une adresse donnée. En particulier, cela va nous permettre de modifier la valeur d’une variable en connaissant uniquement son adresse en mémoire. Pour cela, on va utiliser l’opérateur d’indirection :

L'opérateur d'indirection *
L’opérateur unaire *, aussi appelé opérateur d’indirection, permet d’accéder à la valeur stockée à l’adresse qui est contenue dans un pointeur. Tout comme l’opérateur &, l’opérateur * est préfixé.

Mieux qu’un long discours, voici tout de suite un exemple d’utilisation de cet opérateur :

Exemple 3

Ce qui nous donne le résultat suivant :

exempleaffichagepointeur2

Quelques explications, ligne par ligne :

  • ligne 6 : on déclare p, un pointeur d’entier.
  • ligne 7 : on déclare une variable entière x que nous initialisons à 1.
  • ligne 9 : on affiche la valeur contenue dans x.
  • ligne 10 : on affiche l’adresse de la variable x.
  • ligne 12 : on fait pointer p vers x. Cela signifie que l’on place l’adresse de la variable x dans le pointeur p. Donc, la valeur contenue dans p sera l’adresse de x.
  • ligne 13 : on utilise l’opérateur d’indirection sur p. On accède donc à la valeur contenue à l’adresse contenue dans p. Ici, cela signifie que l’on accède au contenu de la variable x, puisque p contient l’adresse de x. On remplace cette valeur par 5. Ainsi, *p vaut 5 car i vaut 5.
  • ligne 15 : on affiche la valeur contenue dans x pour montrer qu’elle à bien été changée par l’opération de la ligne 13.
  • ligne 16 : on affiche l’adresse de la variable x pour montrer qu’elle n’a pas changé.

Et deux schémas pour montrer l’état de la mémoire avant et après la ligne 13 :

  1. Avant la ligne 13 : memoirepointeurexemple
  2. Après la ligne 13 :memoirepointeurexemple2
Ambiguïté de l'opérateur *
L’opérateur * peut être soit l’opérateur de multiplication soit l’opérateur d’indirection. Utiliser le même opérateur gène la lisibilité du code source. Cependant, cette gène est uniquement humaine puisque l’opérateur de multiplication se trouve toujours entre deux expressions (on dit qu’il est infixe) et que l’opérateur d’indirection se trouve toujours devant une expression (on dit qu’il est préfixe). La grammaire, c’est à dire l’ordre dans lequel les différents éléments d’une expression vont être lus et évalués va lever l’ambiguïté.

Initialiser un pointeur

Nous avons vu dans le cours sur les variables qu’il est fortement recommandé de les initialiser lors de leur déclaration. Pour un entier, il suffit de lui donner une valeur comme 0, 1 ou toute autre valeur ayant une signification à vos yeux. Par convention, on doit initialiser un pointeur à NULL, qui est est constante prédéfinie valant 0.

Exemple d'initialisation de pointeur

Ainsi, un pointeur initialisé à NULL va pointer vers l’adresse 0. Voici comment NULL est definie :

Il faut bien comprendre que lorsqu’un pointeur pointe vers NULL cela revient à dire qu’il ne pointe vers rien. Cette convention permet de déterminer, dans un programme un minimum complexe, si un pointeur à déjà été manipulé depuis sa déclaration, de détecter des erreurs de natures diverses, où même la fin d’une recherche. Pensez donc à bien initialiser vos pointeurs, c’est important.

Le typage des pointeurs

Si vous avez tout compris jusqu’ici, vous devriez vous poser une question fondamentale. Reprenons la déclaration d’un pointeur :

La déclaration d’un pointeur se fait de la manière suivante :

type * nom_du_pointeur; avec type le type de la variable vers laquelle on pointe.

Pourquoi est-ce que type doit être du même type que celui de la variable vers laquelle on pointe ?

Cette question est loin d’être triviale, et la réponse encore moins.

Il faut comprendre comment sont représentées les données en mémoire. En effet, l’espace occupé dans la mémoire par une variable va dépendre de plusieurs facteurs (le type de la variable, l’architecture de l’ordinateur (8/16/32/64 bits), le compilateur etc….). Ces différents facteurs permettent de déterminer le nombre d’octets qu’une variable va occuper en mémoire. Par exemple, sur la plupart des machines, un entier (int) est codé sur 4 octets, mais il existe des machines sur lesquelles le type int sera représenté sur 2 ou 8 octets…

Mauvaise pratique
Vous ne devez jamais présupposer du nombre d’octets utilisés pour représenter un type de donnée en mémoire ! Sinon, votre programme ne sera pas portable, c’est à dire que son comportement (bonne exécution du programme ou plantage ou exécution incohérente) ne sera pas nécessairement le même suivant la machine où il sera exécuté.

En effet, le nombre d’octets utilisés pour représenter un type va avoir une influence sur les valeurs minimum et maximum que peut prendre votre type, par exemple. Un programme affichant tous les entiers positifs peut ne pas fonctionner Si vous supposez qu’un entier est codé sur 4 octets alors qu’il n’est codé que sur 2 … Je vous laisse imaginer la tête du client si vous commettez ce genre d’erreur dans la réalisation d’une application cruciale… Heureusement, il existe une solution très simple pour connaitre le nombre d’octets utilisés par un type, l’opérateur sizeof :

L'opérateur sizeof
Pour connaitre le nombre d’octets utilisés pour stocker une variable, on peut faire appel à l’opérateur sizeof(type), avec type le type de donnée dont on souhaite connaitre la taille.
Par exemple, sur la plupart de nos ordinateur, l’appel de « sizeof(int) » retournera 4 (car 4 octets= 4*8 bits=32 bits). sizeof est bien un opérateur et non une fonction, mais cette distinction ne change rien pour nous pour l’instant.

J’en profite pour faire une remarque philosophique que vous pouvez ignorer sans aucun problème :

sizeof et les octets
Pour être précis, l’opérateur sizeof ne retourne pas le nombre d’octets utilisés pour un type. Il retourne le nombre de char nécessaires pour enregistrer ce type. En effet, le langage C spécifie que l’unité de base est le char, et non l’octet. Cependant, sur la plupart des machines (99.999%, selon mon estimation personnelle) un char est codé sur un octet, ce qui revient donc strictement au même. C’est pour cela que je me permets de parler d’octets plutôt que de char car pédagogiquement cela me semble plus simple. MAIS, le jour où vous aurez à coder sur une machine pour laquelle le char est codé sur 2 octets, vous devrez être sur vos gardes car cela risque d’avoir un impact sur les fonctions d’allocation dynamique.

De plus, le typage des données influence la manière d’interpréter l’ensemble de cases constitué par la variable (voir le cours sur les types de données). Le typage va donc avoir un impact sur notre manière de lire et écrire une variable.

Reprenons l’exemple de représentation de la mémoire : Nous avons les entiers (int) représentés sur 4 octets, les caractères (char) représentés sur 1 octet et les entiers courts (short int) représentés sur deux octets.

memoire3variablesexemple

Lorsque l’on affecte une valeur à une variable, l’ordinateur va donc devoir écrire 4 octets, 1 octet ou 2 octets (à l’adresse mémoire de la variable) en fonction du type de notre variable.

Maintenant, Nous allons faire un raisonnement par l’absurde. C’est à dire supposer quelque chose de faux, pour montrer que cela aboutit obligatoirement à une erreur, ou quelque chose d’absurde. Supposons (ce n’est pas le cas) qu’un pointeur mon_pointeur ne soit pas typé. On lui affecte une valeur, c’est à dire une adresse mémoire, à la main, sans utiliser l’opérateur « & », par exemple : « 0028FF40 ». Maintenant, en utilisant l’opérateur d’indirection je vais écrire une donnée de type char à cette adresse, par exemple le caractère ‘A’ (dont le code binaire est ‘01000001’). Donc, je ne vais modifier que le premier octet (celui à l’adresse 0028FF40) de la variable toto. Pour bien comprendre ce qui se passe, observons ce qui se passe dans la mémoire (en binaire), au niveau des 4 octets de la variable toto.

  1. Voici l’état de la mémoire avant l’écriture de la lettre ‘A’ :memoirepointeurexempletype1
  2. Voici l’état de la mémoire après l’écriture de la lettre ‘A’ : memoirepointeurexempletype2

Maintenant, lorsque l’on va lire la variable toto, elle aura pour valeur 1 090 584 040. Plus aucun rapport avec la valeur de 65000, ni la lettre ‘A’. Pour éviter ce genre de problèmes, les pointeurs doivent avoir un type en lien avec le type de données vers laquelle ils pointent. Ainsi, lorsque l’on va déclarer un pointeur mon_pointeur vers l’adresse 0028FF40, il devra être de type int*. Ensuite, en utilisant l’opérateur d’indirection pour écrire une valeur à cette adresse mémoire, c’est bien 4 octets qui seront écrits.

Les pointeurs, les tableaux et l’arithmétique des pointeurs

Lorsque l’on apprends à programmer, il est très fréquent de se poser des petites questions (par exemple quelles sont les adresses des différentes cases d’un tableau ?). Le mieux, pour y répondre est de faire un petit programme pour tester.

Quelles sont les adresses des cases d'un tableau ?

A votre avis, quelle sera l’affichage de ce programme ? Faites le test, et essayez de comprendre ce que vous voyez.

Avant de vous donner une explication, il est nécessaire de faire un petit rappel :

  • Un tableau est un ensemble de variables de même type, stockées en mémoire de manière contiguës.
  • Chaque variable d’un même type occupe le même nombre d’octets en mémoire
  • La mémoire de l’ordinateur est adressée octet par octet

Donc, ce qui sera affiché c’est l’adresse du premier élément du tableau (notée X pour l’exemple), puis l’adresse de la deuxième case ne se trouve pas sur l’octet suivant, mais sizeof(type) octets plus loin. En effet, si le type des éléments du tableau occupe 4 octets, chaque case du tableau va occuper 4 octets. Comme les cases sont placées les unes à la suite des autres, la deuxième case se trouve donc 4 octets plus loin, la troisième 4 octets plus loin etc…

 

Si vous êtes arrivés jusqu’ici, vous êtes prêts pour une révélation : on vous a menti ! En langage C, les tableaux n’existent pas. Plus précisément, il n’existe pas de structure explicite de type « tableau » mais il existe une syntaxe qui vous fait penser qu’ils existent, les fameux ‘[‘ et ‘]’. Cette syntaxe, c’est ce que l’on appelle du sucre syntaxique, c’est à dire une syntaxe destinée uniquement à simplifier la vie du développeur. Mais simplifier quoi exactement ?

Lorsque j’ai fait mes études, mon prof de C nous a fait apprendre une phrase par cœur. Cette phrase magique va résumer beaucoup de choses. A votre tour de l’apprendre :

La phrase magique
Le nom d’un tableau est un pointeur sur son premier élément.

C’est à dire par exemple que l’expression « &(mon_tableau[0]) » vaut la même chose que l’expression « mon_tableau ».

Quelles sont les implications de cette phrases ? Par exemple, si on souhaite réaliser une affectation dans la première case du tableau, on peut l’écrire soit en utilisant le sucre syntaxique :

soit en utilisant les pointeurs (l’opérateur d’indirection appliqué sur l’adresse de la première case du tableau, c’est à dire… le nom du tableau) :

Maintenant, si on souhaite réaliser une affectation dans la case suivante, on peut être tenté d’utiliser l’instruction suivante :

car on souhaite accéder à la valeur située sizeof(int) octets plus loin que l’adresse du premier élément du tableau. Malheureusement (ou heureusement ?), les choses ne fonctionnent pas de cette manière. Le typage des pointeurs permet au compilateur de connaitre la taille des données et le langage C va utiliser cette propriété pour nous simplifier la vie. Voici la bonne instruction :

Quand on souhaite accéder à l’adresse mon_tableau+ 1, le compilateur comprend que l’on souhaite accéder à l’élément situé 1 case après l’adresse du premier élément mon_tableau soit sizeof(int) octets plus loin. Pratique non ? Reprenons notre programme :

Quelles sont les adresses des cases d'un tableau ?

Il y a équivalence entre les deux notations suivantes :


et

Voila ! C’est la fin de ce cours sur les pointeurs. Ce que j’ai présenté dans ce cours est une vision restrictive des pointeurs mais il me semble judicieux d’arrêter ici. Nous reviendrons plus tard sur des choses plus fines, comme les pointeurs de fonctions par exemple…
 

Leave a Reply