Les pointeurs en C

Après les deux articles précédents, la compréhension de ce qu’est un pointeur devrait être plus facile. En effet, qu’avons nous vu? Un ordinateur comprend un microprocesseur, de la mémoire et des cartes d’entrée/sorties. Lorsque l’on effectue une opération, il va chercher les informations dans la mémoire, il les traite et les restocke dans la mémoire. Ceci implique qu’il sache s’y retrouver dans la mémoire. En gros on peut dire que ce qui se passe revient à ceci : tous les octets de la mémoire sont numérotés. Chacun de ces numéros est appelé l’adresse de l’octet. Les adresses sont stockées sur 32 bits, soit sur 8 chiffres en hexadécimal. Chaque case mémoire a donc une adresse propre, et le microprocesseur a sous la main un fichier dans lequel sont indiquées les adresses de toutes les variables qu’il a besoin de manipuler. Par ailleurs le microprocesseur à sous le coude un autre fichier qui contient toutes les instructions qu’il doit exécuter. Encore une fois je précise que ce n’est pas tout à fait ça. Mais du point de vue du programmeur C qui découvre les pointeurs, ça suffira.

a) Les adresses :

Donc lorsque l’on déclare une nouvelle valeur, par exemple un tableau T de 15 entiers, le microprocesseur sait que la mémoire requise va être 15x “le nombre d’octets nécessaire pour le stockage d’un entier”. Soit 60 octets si on est sur un ordinateur où chaque int est stocké sur 4 octets. Le microprocesseur va donc reserver quelque part dans la mémoire (où il veut, c’est son problème du moment que la place est libre) 60 octets consécutifs. Puis il va noter quelques part que ces octets ne sont plus libres, et il va noter dans son fichier de variables l’adrese du premier octet du tableau. Vous avez peut être déjà essayé de faire printf(“%d”,T); T étant un tableau, et obtenu un curieux résultat du style 0×8ab45fe5, eh bien c’est précisément ça : vous avez affiché l’adresse du premier octet du tableau T. De même si vous tapez printf(“%d”,&a); dans un programme, a étant un entier quelconque, vous allez afficher l’adresse du premier des octets dans lesquels a est stocké. Et quand vous écrivez scanf(“%d”,&a); vous demandez au microprocesseur de stocker la valeur saisie au clavier à l’adresse de a, tout simplement. Attention tout de même, car si dans le cadre du tableau, vous tapez printf(“%d”,T+1); vous n’allez pas obtenir 0×8ab45fe6, adresse du deuxième octet sur lequel est stocké le premier entier de T, mais plutôt 0×8ab45fea9 correspondant au premier octet sur lequel est stocké l’entier suivant. En effet le compilateur sachant que T est un tableau d’entiers sait qu’il faut sauter 4 octets pour se placer sur l’entier suivant. C’est pour ça que &T[i] et (T+i) reviennent au même, tous deux désignant l’adresse du ième entier de T. A partir de maintenant nous allons donc pouvoir distinguer deux types de variables : celles qui contiennent des données et celles qui contiennent des adresses. Vous l’aurez deviné, les variables qui contiennent des adresses sont appelées des pointeurs. En effet depuis la case mémoire où elles sont stockées, elles pointent vers les cases mémoires dont elles contiennent l’adresse.

b) Les pointeurs :

Bon maintenant qu’ils ont si bien été introduits, il ne reste plus grand chose à dire sinon peut être qu’ils sont l’essence du C. Jusqu’à maintenant on ne vous en avait pas parlé car ce n’était pas nécéssaire, mais en fait ils sont omniprésents même si cela ne saute pas aux yeux. C’est notamment grâce aux pointeurs qu’une fonction peut modifier plusieurs variable : on lui passe en argument les adresses des cases mémoires qu’il va falloir lire et modifier, et elles peut ainsi modifier les variables même si elle ne retourne rien. Les pointeurs sont également très utiles dés lors que l’on veut utiliser des structures complexes, créer des listes, files, ensembles, graphes, etc, et optimiser la place mémoire requise. Un exemple typique est la liste linéaire chainée : chaque cellule contient l’adresse de la suivante. On se sert aussi de pointeurs pour tout ce qui est tableau : int *t; est typiquement la manière de déclarer un pointeur vers un ou des entiers. Et si on fait un tableau à deux dimensions, la déclaration devient int **t, qui se décompose en (int *) *t pour un pointeur vers des pointeurs vers des entiers. La structure typique est la suivante : dans la n-ièmecase du tableau de pointeurs est stocké le pointeur de la n-ième ligne du tableau.

Allocation Dynamique

Nous avons introduit le fonctionnement de base de l’ordinateur. Bien que l’allocation dynamique soit déjà un degré de complication au-dessus des premiers exercices, sa compréhension ne devrait pas poser de problèmes dés lors que le I est bien assimilé. Nous allons l’introduire au moyen de quelques exemples :

a) Allocation statique:

Supposons que l’on pose à un étudiant le sujet suivant : “Le programme doit demander à l’utilisateur de rentrer 5 nombres au clavier, les stocker dans un tableau, puis les afficher”. Un étudiant normal codera à peu près ceci :

int main(void)
{
int i;
int t[5];

./*PHASE DE SAISIE*/
puts(“entrez 5 entiers”);
for(i=0;i<5;i++)
scanf(“%d”,t+i);

.

/*PHASE D’AFFICHAGE*/
puts(“vous avez saisi : “);
for(i=0;i<5;i++)
printf(“%d, “,t[i]);

printf(“\n”);
}

.
.
/*declaration du compteur de boucle*/
/*declaration d’un tableau statique de 5 entiers*/
.
/*equivalent de printf(“entrez 5 entiers\n”)*/
/*initialisation de i à 0 puis avant chaque boucle, vérification que*/ /*i<5, puis exécution des instructions de la boucle, enfin*/
/*incrémentation de i.*/
/*boucle : saisie d’un nombre au clavier et stockage à l’adresse*/ t+i*/

.

/*le %d indique que l’on va afficher un entier. t[i] est la valeur*/
/*de la i ème*/
/*case du tableau (on compte à partir de zéro)*/
/*pour passer à la ligne en fin d’éxécution*/

Bon les commentaires sont là pour faciliter la compréhension de tous. Mais compte tenu de leur lourdeur et du fait que ce n’est pas une partie de plaisir de mettre des tabulations et autres procédés de mise en forme en html, c’est un luxe que je ne vais pas longtemps me permettre. Voyons tout de suite le problème suivant : “Le programme doit demander à l’utilisateur de choisir un nombre n puis de rentrer n entiers au clavier, de les stocker dans un tableau, puis de les afficher”.

La première solution qui vient à l’esprit du programmeur qui n’aime pas trop réfléchir et qui préfère extrapoler à partir de l’exemple d’avant consiste à rajouter au début du code précédent “int n; printf(“Donne n>”); scanf(“%d”,&n); ” puis de remplacer les 5 par des n, puis de se demander pendant une demi heure pourquoi le int t[n]; ne passe pas à la compilation (Si vous avez des doutes, relire le précédent article ne sera pas du luxe).

Il convient alors de se rappeller que nulle mémoire n’est infinie et que quelque soit le mécanisme qui va limiter la taille du tableau, il ne peut pas ne pas en exister un qui n’impose une limite (à moins peut être de travailler sous windows ;=) ), et ça peut très bien être vous. En allocation statique il n’existe pas vraiment de manière élégante de s’en tirer. Le plus simple est alors de supposer que l’utilisateur ne choisira pas un nombre plus grand qu’un maximum que vous fixez. Vous pouvez par exemple déclarer un tableau de 1024 entiers {int t[1024];} en supposant qu’aucun utilisateur ne sera jamais assez courageux pour choisir plus de 1000 nombres. Par contre ne faites que n boucles ;) Les puristes rajouteront un petit test pour afficher un message d’erreur dans le cas où le n choisi est supérieur à 1024. Voici un exemple de ce que vous pouvez faire :

int main(void)
{
int i,n;
int t[1024];

/*PHASE DE SAISIE*/
puts(“combien de nombres voulez vous rentrer?”);
scanf(“%d”,&n);

printf(“donnez %d nombres \n”,n);
for(i=0;i<n;i++)
scanf(“%d”,t+i);

/*PHASE D’AFFICHAGE*/
puts(“vous avez saisi : “);
for(i=0;i<n;i++)
printf(“%d, “,t[i]);
printf(“\n”);
}

b) Allocation Dynamique:

Le problème de la solution que l’on vient d’exposer est qu’elle est un peu bancale notamment au niveau de l’optimisation de l’espace mémoire, mais ce n’est que le problème le plus évident et il y en a d’autres. La solution est simple : il faut allouer soi même la mémoire dont on va avoir besoin en spécifiant la manière dont on va l’utiliser pour que le compilateur ne soit pas perdu. Pour cela on utilise des instructions spécifiques dont malloc et calloc. Je vais expliquer pour le calloc car c’est celui qui s’applique le mieux à notre exemple : pour réserver un tableau de n entiers on va écrire :

nt *t=(int*)calloc(n,sizeof(int));

et en voici la traduction :

  • ‘int *t’ =>on déclare un tableau d’entiers.
  • ‘calloc(n, sizeof(int))’ => on réserve pour ce tableau la place nécessitée par le stockage de n entiers sachant que sizeof(int) retourne le nombre d’octets utilisés pour stocker un entier. Il en découle pour le compilateur que le nombre d’octets à réserver est donc n*sizeof(int).
  • ‘(int*)’ est un cast c’est à dire un opérateur de conversion qui va indiquer au compilateur que l’espace que l’on va alouer va correspondre à un tableau d’entiers.

Pour les curieux, la petite étoile a un rapport avec quelque chose d’assez mystérieux appelé les pointeurs, nous y viendrons bien assez tôt. Et pour les malins qui ont repéré que sizeof(int) retournait 4 sur leur ordinateur et qui se contentent donc d’écrire “int *t=(int *)calloc(n,4);”, pensez que vous exécuterez peut être un jour votre programme sur un ordinateur qui utilise 8 octets pour stocker un int. Ce jour là vous aurez l’air malin (et ce d’autant plus que vous n’aurez pas de bug à la compilation, mais seulement à l’éxécution et encore pas systématiquement, un coup à s’arracher les cheveux). Bref voici donc comment traiter le problème précédent en utilisant l’allocation dynamique :

int main(void)
{
int i,n;
int *t;

/*PHASE DE SAISIE*/
puts(“combien de nombres voulez vous rentrer?”);
scanf(“%d”,&n);
t=(int *)calloc(n, sizeof(int));

printf(“donnez %d nombres \n”,n);
for(i=0;i<n;i++)
scanf(“%d”,t+i);

/*PHASE D’AFFICHAGE*/
puts(“vous avez saisi : “);
for(i=0;i<n;i++)
printf(“%d, “,t[i]);
printf(“\n”);
}

Il y a bien sûr encore pas mal de choses à dire sur les allocations dynamiques, la différence entre le calloc et le malloc, et l’instruction realloc, mais ce n’est pas l’objet de cette page qui veut avant tout faire comprendre le principe et l’esprit de la programmation C plutôt qu’un exposé rigoureux de ses fonctionnalités. Si vous avez un doute sur une instruction, pensez à ouvrir un terminal et à taper ‘man’ suivit du nom de l’instruction pour avoir des informations sur son mode d’utilisation.

Quelques prérequis à la compréhension du C

Alala les erreurs de segmentation, quelle plaie, n’est-ce pas? Heureusement nous n’allons pas commencer par là, même si nous y viendrons bien vite, alors ne nous pressons pas trop. Voyons pour l’instant les quelques prérequis de base: Nous allons tout d’abord évoquer rapidement le fonctionnement de base d’un ordinateur, avant d’évoquer la manière dont le C fonctionne. Enfin nous expliquerons en détail le fonctionnement de certaines commandes épineuses.

a) Fonctionnement de l’ordinateur:

Comme chacun le sait, les ordinateurs ne sont pas magiques, bien qu’on puisse en avoir un peu l’impression tellement les effets que l’on obtient actuellement sont extraordinaires. Et je crois qu’il est bon de le garder à l’esprit car trop souvent on est porté à les considérer comme des artefacts au pouvoir mystérieux.

Pour faire simple, un ordinateur marche grâce à un microprocesseur, de la mémoire, et un ensemble de composants permettant la communication avec l’environnement extérieur. Il y a deux types de mémoires : les mémoires de stockage, tels les disques dur, clés USB, et CDs, et les mémoires de travail aussi appelées mémoires RAM dans lesquelles sont chargées les données dont le microprocesseur a besoin pour travailler. Le microprocesseur est une puce qui contient des millions de transistors. Il effectue les calculs et traite les requêtes. Pour cela il va chercher les instructions dans la mémoire de travail, et y stocke les résultats qu’il obtient. Diverses cartes également bourrées de transistors interprètent ces résultats pour l’utilisateur. Les mémoires de stockages sont des éléments externes au microprocesseur. Chaque fois qu’il a besoin d’y lire ou d’y stocker des données, il est donc ralenti, c’est pourquoi on s’efforce généralement de limiter ces besoins en loadant une bonne fois pour toute les données requises dans la mémoire de travail.

b) Introdution au C

Ce n’est pas transparent pour l’utilisateur lambda, mais lorsque l’on demande à un ordinateur d’additionner deux entiers a et b, le microprocesseur doit connaitre l’endroit où se trouvent les entiers a et b, sur combien d’octets ils sont stockés et où il doit écrire le résultat. C’est pourquoi en C on déclare les variables que l’on va utiliser en spécifiant quel est leur type. Ainsi lors de la déclaration d’une variable (par exemple : “int var;” ) le microprocesseur réserve quelque part dans la mémoire de travail les octets nécessaire pour le stockage d’une variable de type int, il retient l’adresse de ces octets, et prend note que chaque fois que l’utilisateur utilise ou modifie la variable var, c’est à cet endroit là qu’il devra aller voir.

Mais en général on ne parle pas de microprocesseur directement mais de compilateur. C’est tout simplement parce que le microprocesseur ne sait parler qu’en binaire ou a la rigueur en hexadécimal, tandis que l’utilisateur programme dans un langage spécifique. Il est donc nécessaire d’utiliser un programme appelé compilateur qui va traduire tout ce que l’utilisateur a demandé pour que le microprocesseur puisse le comprendre. Il arrive malheureusement que l’utilisateur ait demandé quelque chose qui n’est pas logique, ou qui n’est pas compréhensible, et le compilateur est alors très malheureux car il ne sait pas quoi dire au microprocesseur. En général il réagit en demandant des précisions à l’utilisateur (il marque “erreur de quelque chose dans le fichier truc.c en ligne n”), mais parfois il fait à son idée, ce qui peut générer des erreurs assez cocasses à l’exécution.

c) Quelle différence entre int t[5]; et int t[n]; ?

Cet exemple va vous permettre de voir si vous avez bien tout compris. Le compilateur qui voit “int t[5];” est content. En effet il sait qu’il doit dire au microprocesseur de réserver la place nécessaire au stockage de 5 entiers. Par contre lorsque le compilateur voit “int t[n];”, il est tres malheureux. En effet il sait qu’il doit dire au microprocesseur de réserver la place nécessaire pour stocker des entiers, mais il ne sait pas combien. Donc il en fait part à l’utilisateur qui doit donc s’y prendre autrement lorsqu’il veut réserver un tableeau d’un nombre indéterminé d’entiers. C’est l’objet du prochain aticle.

Introduction au langage C

schéma de l'inclusion en C

Dans le cadre de mon dernier site, j’avais écrit quelques articles sur le langage C. Ces articles étaient très orientés débutants. Je vais donc dans le cadre du transfert de mon ancien site vers celui ci les poster dans mes prochaines publications. Ces articles sont avant tout destiné aux gens qui prendraient des cours de langage C, mais seraient un peu largués. Ainsi on s’attendra à ce que le lecteur connaisse certaines commandes de base du C. Par contre on s’attachera à réexpliquer le comment du pourquoi, et la raison pour laquelle le compilateur accepte certaines choses et pas d’autres. On fera également le lien entre ce qui se passe dans le programme et ce qui se passe au même moment au coeur de l’ordi de manière à mettre en évidence l lien entre les deux.

Voici le sommaire:

  1. Quelques prérequis à la compréhension du C.
  2. Allocation Dynamique.
  3. Les pointeurs.

Je n’ai bien sûr pas la prétention d’expliquer toute la philosophie du C en trois petits articles, mais je pense que leur contenu aurait pu être d’un grand secours à pas mal de mes condisciples moins habitués que moi à la programmation au moment où nous avons découvert les joies de la programmation en C :) .