1.5- Les pointeurs et la gestion de la mémoire
Transcription
1.5- Les pointeurs et la gestion de la mémoire
1.5- Les pointeurs et la gestion de la mémoire 1- Notion de pointeur, déclaration et initialisation 2- La gestion de la mémoire et ses dangers 3- Opérations sur les pointeurs 4- Pointeurs et tableaux 5- Pointeurs et fonctions 6- Transtypage des pointeurs 1 Notion de pointeur, déclaration et initialisation La mémoire est organisée comme une série d'espaces qu'on peut remplir de valeurs ou dont on peut accéder le contenu. Généralement l'octet est l'espace de base. Chaque octet a un numéro unique, une adresse. La mémoire vive est divisée en emplacements d'un octet numérotés séquentiellement : Numérotation des octets 34 35 36 37 Bits de chaque octet 10100001 10111011 10101010 10110001 Dès que l'on défini un programme, les variables et les fonctions que l'on utilise occupent un espace en mémoire et ont une adresse qui leur est associée. Pour une variable ou une fonction, l'adresse qui lui est associée est le numéro du premier octet à partir duquel elle est stockée. Si un char, de taille 1 octet, est à l'adresse 34, cela signifie que ce sont les bits de l'adresse 34 qui définissent la valeur du char. Si un int, de taille 4 octets, est à l'adresse 34, cela signifie qu'il est codé sur les octets 34-35- 36-37. Un pointeur est une variable particulière, c'est une variable qui contient une adresse, soit le numéro d'un octet à partir duquel est stocké une valeur. Opérateur & Pour obtenir l'adresse d'une variable (l'adresse du premier octet à partir duquel cette variable est stockée), on utilise l'opérateur &. Soit le code suivant : #include <isotream> int main() { int i=0; std::cout << "L'adress de i : " << &i; } On constate que l'affichage est une valeur en base hexadécimale lorsque l'on exécute ce code. Pour obtenir une valeur entière, on procède à un transtypage : #include int main() { int i=0; std::cout << "L'adresse de i : " << (long)&i; } On peut tester le cas d'une variable globale : #include <iostream> int a; double y; int main() { int i=0,j; double x; std::cout << "L'adress de i : " << (long)&i << "\n"; std::cout << "L'adress de j : " << (long)&j << "\n"; std::cout << "L'adress de x : " << (long)&x << "\n"; std::cout << "L'adress de y : " << (long)&y << "\n"; std::cout << "L'adress de a : " << (long)&a << "\n"; } Sur un run, cela donne sur l'ordinateur du rédacteur de ce cours : L'adress de i : 2293620 L'adress de j : 2293616 L'adress de x : 2293608 L'adress de y : 4468760 L'adress de a : 4468752 On constate que les variables globales ne sont pas stockées au même endroit que les variables locales à la fonction main() ici. On constate également que la numérotation est consécutive, avec les adresses qui sont séparées de la taille de la variable déclarée. Déclarer une variable pointeur Soit la déclaration suivante : int *pA=0; Ce code déclare pA non pas comme une variable entière, mais comme un pointeur sur un entier de type int. Donc pA stockera l'adresse d'une variable de type int. On peut tester la taille réservée à un pointeur en mémoire vive : std::cout << "La taille pour socker un pointeur : " << sizeof(int*) << "\n"; std::cout << "La taille pour socker un pointeur : " << sizeof(double*) << "\n"; On constate qu'un pointeur est stocké sur 4 octets, quelque soit le type sur lequel il pointe. Ce résultat aurait pu être anticipé : la forme de l'adresse d'un octet ne change pas, qu'il participe du stockage de la valeur d'un int ou d'un double. Il est bon de déclarer les variables pointeurs en faisant en sorte que leur nom commence par p, cf le fait que pour l'exemple précédent, on déclare int *pA; Ceci fait partie des conventions qui permettent de faciliter la lecture du code, mais qu'il n'est pas obligatoire d'adopter (en l'occurrence, celle-ci est très peu répandue, mais elle est peut être utile pour débuter). La machine sait que * est un opérateur sur pointeur et non pas l'opérateur de multiplication en fonction du contexte. Pour la déclaration suivante : int* a,b,c; Dans ce cas, a est un pointeur sur entier, b est un entier et c un entier. L'opérateur * est associé à l'identifieur a dans ce qui précède et non pas au type int. Ce sera toujours le cas. Pour affecter l'adresse d'une variable à un pointeur : int a=5; int *pA; pA=&a; Bien sûr, pour affecter une adresse d'une variable à un pointeur, il faut que la variable et le pointeur soit compatible. Ie si a est de type entier, il faut que pA soit de type int*, ie qu'il soit un pointeur sur entier. Indirection L'indirection consiste à accéder la valeur contenue à l'adresse enregistrée dans un pointeur. On parle aussi de déréferencement d'un pointeur. Dans le cas d'une variable standard, récupérer son contenu n'est pas difficile : double y; double x=7; y=x; Dans ce qui précède, on récupère le contenu de x dans y. Pour accéder la valeur d'un pointeur : double y; double x=7; double *pZ=&x; // pZ est un pointeur qui contient l'adresse de x y=*pZ; L'opérateur d'adressage indirect (*) devant le nom d'une variable signifie "Valeur stockée à l'adresse". double x=5; double *pX=&x; *pX=7; cout << "La valeur de x : " << x; Ici la valeur de x est 7 après la manipulation. On modifie la valeur de x en modifiant la valeur stocké à l'adresse de x, cette adresse étant stockée dans pX. Noter une autre écriture équivalente mais décomposée : double x=5; double *pX; pX=&x; *pX=7; cout << "La valeur de x : " << x; Noter bien la différence entre l'affectation d'un pointeur conjointe à sa déclaration et l'affectation d'un pointeur hors de sa déclaration. Un danger grave lié à la manipulation des pointeurs : manipuler un pointeur sans lui affecter d'adresse double x=5; double *pX; cout << (long)pX << "\n"; *pX=7; cout << "La valeur de x : " << x; Ce code peut s'exécuter et pourtant il contient une erreur très grave. En effet, on déréference le pointeur pX, mais sans que l'on ait affecté d'adresse à ce pointeur. Quelle adresse contient donc le pointeur ? Lorsque la machine crée la variable pointeur pX, elle lui affecte un emplacement. L'adresse contenue dans ce pointeur est donc totalement imprévisible : elle dépend de ce qu'il y avait auparavant dans la mémoire. Une bonne méthode peut consister à mettre tout pointeur à NULL ou à 0 (ce qui est équivalent) lors de sa déclaration (ou bien lui affecter l'adresse d'une variable précédemment déclarée) : double x=5; double *pX=0; //on aurait pu mettre également double *pX=NULL; cout << (long)pX << "\n"; *pX=7; cout << "La valeur de x : " << x; Dans ce cas, le code produirait une erreur lors de l'exécution : au moins le concepteur est conscient du problème lorsqu'il a initialisé le pointeur à NULL (0). De manière générale, la manipulation des pointeurs peut conduire à un certain nombre d'erreurs. Leur manipulation doit s'accompagner de parcimonie au risque sinon de modifications aux conséquences imprévisibles. Une précaution particulière doit être apportée à l'initialisation des pointeurs. Il faut donc ne jamais laisser de déclaration "sèche" de la forme : double *pX; Toujours concevoir le code de manière à avoir : double pX=&y; ou à tout le moins : double pX=0; // ce qui est equivalent à double pX=NULL; Pointeur sur pointeur Il est bien sur possible de définir des pointeur sur pointeurs : int **t; Ici t est un pointeur sur pointeur d'entier. Il contient l'adresse d'une variable qui contient l'adresse d'un entier. Nous ne rentrons pas plus dans les détails ici. Usage des pointeurs Pourquoi manipuler des pointeurs ? • Passer des variables par référence à des fonctions / création de fonctions avec plusieurs variables de retour. • Créer des structures de données ad hoc en fonction d'un problème. On pourra créer les structures de données qui prennent le moins en temps et en espace. • Les pointeurs sont à la base d'optimisations significatives. • Utilisation du tas Les usages seront développés et exemplifiés plus tard. En plus des usages qui peuvent être présentés ici, ils seront très utiles pour la manipulation des objets. 2 La gestion de la mémoire et ses dangers Organisation de la mémoire Cf le chapitre sur les fonctions : la création de variables se fait dans la pile pour les variables locales aux fonctions. Il existe un autre mode de stockage des variables. Lors de l'exécution d'un programme, une zone de mémoire est réservée pour ce programme. Au sein de cette zone de mémoire, on distingue notamment deux espaces : la pile associée au programme et le tas. Ce tas représente une série d'octets destinés à recevoir des données. Il ne sera libéré qu'à la fin de l'exécution du programme. Dans ce tas, le programmeur peut librement affecter des octets au stockage de variables. Ce stockage n'est plus géré automatiquement comme c'est le cas pour les variables locales ou globales qui sont stockées dans la pile. C'est au programmeur de prévoir les instructions d'affectation et de libération de la mémoire. Les pointeurs permettent de manipuler les données qui sont stockées dans le tas. new et delete new et delete sont deux opérateurs de C++ qui remplacent une série d'opérateurs du C : malloc, calloc, realloc et free. On commence par rappeler le cas des variables locales qui sont stockées dans la pile. Soit le code suivant : void methode1() { int j; for(int i=0;i<7;i++) { int j2; } } Dans ce cas, la variable j est locale à methode1() : c'est à dire qu'elle n'existe que dans le bloc d'instructions (l'espace entre les deux accolades) qui correspond à la définition de la fonction, cette variable est stockée dans la pile lors de l'exécution. L'espace qui lui est dédiée dans la pile sera libéré à la fin de l'exécution de la fonction De la même façon, la variable j2 est locale au bloc d'instructions de la boucle. Pour des variables locales à un bloc d'instructions, l'espace qui leur est dédié est crée dans la pile au début de l'exécution de ce bloc d'instruction, il est libéré ensuite à la fin de ce bloc d'instructions. void variation(int j,double x) { .... } Dans ce cas, le passage des deux paramètres se fait par valeur : la machine crée un double local à la fonction, de même qu'un entier local à la fonction. Dans le cas où les variables sont créées comme des variables locales à un bloc d'instructions, elles sont là aussi gérées dans la pile et l'espace qui leur est associé sera libéré à la fin de l'exécution. Jusqu'à maintenant, le cours n'a fait que déclarer des variables dans la pile. Il est possible de manipuler des variables qui ne sont pas locales à un bloc d'instructions : c'est à dire déclarer des variables dans la partie de la mémoire vive qui s'appelle le tas. Si une variable est déclarée dans cette partie de la mémoire vive, qu'elle soit déclarée au sein d'une fonction, d'une boucle, d'une instruction conditionnelle, elle ne sera pas libérée lors de la fin d'exécution de la fonction, de la boucle, de l'instruction conditionnelle. Pour déclarer une variable entière dans le tas alors qu'on dispose d'une variable pX de type int* : pX=new int; Avec cette instruction, un espace mémoire de la taille d'un int sera reservé dans le tas. Par suite, il faudra libérer la mémoire vive qui a été réservée : delete pX; L'instruction delete pX libère la mémoire vive dans le tas, mémoire qui avait été réservée pour la variable pX. Bien sûr, à la fin de l'exécution du programme, le tas est libéré. Exemple complet #include <iostream> int main() { int localVar=5; int *pLocal=&localVar; int *pHeap=new int; *pHeap=7; cout << "localVar : " << localVar <<< "\n"; cout << "*pLocal : " << *pLocal << "\n"; cout << "*pHeap : " << *pHeap << "\n"; delete pHeap; pHeap=new int; *pHeap=9; cout << "*pHeap " << *pHeap << "\n"; delete pHeap; return 0; } Dans le code précédent on illustre la création de variable dans le tas (new int) et la destruction de cette variable (delete pHeap). Un autre exemple : #include <iostream> using namespace std; int* triABulle(long tab[],int nbElts) { bool b=true; int *nbEchanges=new int; *nbEchanges=0; while(b) { b=false; for(int i=0;i<nbElts-1;i++) { if(tab[i]>tab[i+1]) { long echange=tab[i]; tab[i]=tab[i+1]; tab[i+1]=echange; b=true; (*nbEchanges)++; } } } return nbEchanges; } int triABulle2(long tab[],int nbElts) { bool b=true; int nbEchanges=0; while(b) { b=false; for(int i=0;i<nbElts-1;i++) { if(tab[i]>tab[i+1]) { long echange=tab[i]; tab[i]=tab[i+1]; tab[i+1]=echange; b=true; nbEchanges++; } } } return nbEchanges; } int main(){ srand(time(0)); int nbElements=10; long tableau[nbElements]; for(int i=0;i<nbElements;i++) tableau[i]=rand(); int *nbEchs=triABulle(tableau,nbElements); std::cout << "Le nombre des echanges : " << *nbEchs << "\n"; delete nbEchs; for(int i=0;i<nbElements;i++) tableau[i]=rand(); int nbEchas=triABulle2(tableau,nbElements); std::cout << "Le nombre des echanges : " << nbEchas << "\n"; } Dans cet exemple, on trie un tableau par la méthode du tri à bulle et on renvoie un pointeur sur une adresse dans le tas qui contient le nombre d'échanges effectués pas la fonction alors que l'autre fonction de tri renvoie un entier contenant le nombre d'échanges. A noter qu'à chaque appel de la fonction triABulle, une nouvelle variable est réservée dans le tas. Danger de la gestion directe de la mémoire - Fuite de mémoire Réaffecter une valeur à un pointeur alors que celui est l'unique référence d'un espace mémoire fait perdre la référence d'un espace mémoire que la machine considère comme affecté. Par exemple : int *pX=new int; pX=new int; On a perdu la référence sur le premier espace mémoire en faisant cela, mais la machine ne considère pas cet espace comme disponible. Dans le tas, l'espace pour un entier a été réservé, mais on ne dispose plus de l'adresse à laquelle il a été enregistré. Il y a là un espace considéré comme occupé par la machine qui n'est pas libérable. Cet espace est perdu pour le temps du programme. Un autre exemple de fuite de mémoire : on crée un pointeur comme une variable locale d'une fonction. On initialise ce pointeur avec new : à moins de renvoyer la valeur du pointeur par la fonction (ou solution équivalente), on perd de nouveau l'adresse de l'espace réservé pour l'entier qui pourtant est occupée par un entier. Danger des pointeurs - delete Il ne faut pas utiliser un pointeur pour lequel on a libéré l'espace mémoire delete. Une bonne méthode consiste à affecter la valeur nulle à un pointeur après avoir liberé l'espace mémoire qu'il pointe, sinon, si on réaccède cet espace mémoire, on risque de provoquer une erreur. Soit le code suivant, il produit une erreur : 3 Opérations sur les pointeurs Que se passe t'il si on rajoute 1 à une variable de type pointeur ? #include <iostream> int main() { int n; int *pN=&n; double x; double *pX=&x; std::cout << "Taille de int : " << sizeof(int) << " Taille de double : " << sizeof(double) << "\n"; std::cout << (long)pN << " " << (long)(pN+1) << " " << (long)(pN+2) << "\n"; std::cout << (long)pX << " " << (long)(pX+1) << " " << (long)(pX+2) << "\n"; } L'exécution sur la machine de l'auteur : Taille de int : 4 Taille de double : 8 2293620 2293624 2293628 2293608 2293616 2293624 On constate que l'adresse obtenue en ajoutant 1 au pointeur est l'adresse du pointeur augmentée de la taille d'un entier lorsque le pointeur est un pointeur sur entier, augmentée de la taille d'un double quand le pointeur est un pointeur sur double. En ajoutant 1 à un pointeur sur entier d'adresse x, on obtient un pointeur sur x+taille d'entier, en ajoutant 1 à un pointeur sur double, on obtient un pointeur sur x+taille double. On verra que pour les objets c'est la même chose. On peut manipuler les adresses en utilisant les opérateurs : +, -, ++, - -. Il faut cependant faire attention à l'ordre de priorité des opérateurs, ainsi, si nbEchange est un pointeur sur entier (int*), les deux opérations suivantes sont différentes *nbEchanges++; est différent de (*nbEchanges)++; Dans le premier cas, on incrémente la valeur du pointeur de 1 : on obtient le premier octet suivant l'entier. Dans l'autre cas, on augmente la valeur de l'entier pointé par nbEchanges. On peut conclure de ce petit exemple que l'opérateur *, ou opérateur d'indirection, est prioritaire sur l'opérateur ++ (de manière générale, il sera prioritaire sur l'ensemble des opérateurs de calcul). 4 Pointeurs et tableaux Un tableau On peut tester les adresses des différents entiers contenus dans un tableau d'entiers : #include <iostream> int main() { int tab[5]; for(int i=0;i<5;i++) std::cout << &tab[i] << " "; std::cout << "\n" << (long)tab; } On peut obtenir un résultat de la forme : 2293584 2293588 2293592 2293596 2293600 2293584 On constate que les différentes variables contenues dans le tableau sont stockées les unes à la suite des autres, séparées seulement par un espace de la taille du stockage d'une variable du type du tableau. Une autre remarque à faire sur le code précédent : le nom du tableau se comporte comme une variable de type pointeur qui contient l'adresse du premier élément du tableau. De fait, il devient possible de manipuler l'ensemble des éléments d'un tableau sans utiliser la syntaxe habituelle consacrée à cet usage, en récupérant l'adresse du tableau dans une variable de type pointeur : #include <iostream> int main() { int tab[5]; int *a=tab; } Ainsi, on dispose désormais de deux moyens de parcourir un tableau : Ce code ne provoque pas d'erreur : c'est qu'un tableau d'entiers est conservée comme une adresse en mémoire. En créant un tableau de 5 entiers, on réserve 5 espaces de taille int en mémoire, on retient l'adresse du premier. On dispose donc de deux moyens pour parcourir le tableau : #include int main() { int tab[5]; int *a=tab; tab[0]=0; tab[1]=1; tab[2]=2; tab[3]=3; tab[4]=4; for(int i=0;i<5;i++) std::cout << *(tab+i) << " "; std::cout << "\n"; for(int i=0;i<5;i++) std::cout << tab[i] << " "; } Attention un tableau n'est pas un pointeur dans la norme. A noter que non seulement il est possible de manipuler une variable de type tableau comme un pointeur, il est également possible de manipuler une variable de type pointeur comme un tableau : #include <iostream> int main() { int c=7,b=6,a=5; std::cout << (long)&a << " " << (long)&b << " " << (long)&c << "\n"; //on constate que les espaces utilisés pour le stockage des variables sont consécutifs en mémoire. int *pA=&a; pA[2]=10; std::cout << a << " " << b << " " << c << "\n"; } Un tableau dans le tas Il est possible de déclarer un tableau dans le tas, pour ce faire : int *t=new int[100]; Elimination d'un tableau dans le tas : delete[] t; Tableau de pointeurs Il est bien sûr possible de manipuler des collections d'adresses, soit des tableaux de pointeurs : int *t[100]; (note, pour un tableau de pointeurs dans le tas : int **t=new int*[10];) 5 Pointeurs et fonctions Passage par valeur Pour constater le passage par valeur, on montre que les variables créées sont à une adresse différente de celle qui sont utilisées pour l'appel : #include <iostream> void f1(int x){ std::cout << "Execution de f1 : " << (long)&x << "\n"; } int main(){ int n=3; std::cout << "Execution de main : " << (long)&n << "\n"; f1(n); } En exécutant ce code, on obtient : Execution de main : 2293620 Execution de f1 : 2293584 On constate que les variables f1 et n sont stockées à deux adresses différentes : cf donc le passage par valeur. Pointeur en paramètre : passage par référence Les pointeurs rendent possible le passage par référence de variables. Pour passer des variables par référence, on passe l'adresse d'une variable en paramètre plutôt que sa valeur. Soit par exemple une fonction qui calcule les racines d'un polynôme. Cette fonction peut devoir renvoyer non pas une mais deux valeurs, on peut proposer un prototype de la forme : int solvePolyDeg2(double a,double b,double c,double* racine1,double *racine2); La fonction prend un premier paramètre qui est le coefficient du terme de degré 2, un second paramètre qui est le coefficient du terme de degré 1, un dernier paramètre ou terme constant. racine1 et racine2 sont deux pointeurs sur double : on passe l'adresse de deux variables : ces adresses sont celles de deux variables qui ont été déclarées dans la fonction appelante. La valeur ou les valeurs calculées pour les racines seront placées à ces adresses. La fonction renvoie le nombre des racines qui ont été découvertes. Avant de voir son appel, on définit la fonction : int solvePolyDeg2(double a,double b,double c,double* racine1,double *racine2){ double deter=b*b-4*a*c; if(deter>0) { double rac=pow(deter,0.5); *racine1=(-b-rac)/(2*a); *racine2=(-b+rac)/(2*a); return 2; } else if(deter==0) { *racine1=-b/(2*a); *racine2=*racine1; return 1; } else return 0; } L'appel se fait alors de cette manière : int main(){ double sol1; double sol2; int nombreRacine=solvePolyDeg2(1,-2,1,&sol1,&sol2); if(nombreRacine==0) std::cout << "Pas de racine"; else if(nombreRacine==1) std::cout << "Racine unique : " << sol1; else std::cout << "Deux racines : " << sol1 << " et " << sol2; } Dans ce qui précède, on crée deux variables sol1 et sol2 dans main. On passe l'adresse de ces deux paramètres lors de l'appel de la fonction solvePolyDeg2 : lors de l'exécution de cette fonction, deux variables locales sont créées qui sont de type double*, soit des pointeurs sur double. Lors de l'appel, les adresses des variables sol1 et sol2 de main sont copiées dans les variables racine1 et racine2 qui sont locales à la fonction solvePolyDeg2. Ensuite, la manipulation de *racine1 et *racine2 permet de mettre des valeurs dans sol1 et sol2. A noter que le passage par référence n'est jamais que le passage par valeur d'une adresse : on passe comme paramètre une adresse pour remplir une variable pointeur. Pointeur en sortie Pour écrire une fonction qui renvoie un pointeur sur un entier : int *f1() { } Ici la fonction f1 renvoie un pointeur sur entier. Adresse des fonctions Une fois qu'une fonction est compilée et que le programme dont elle fait partie est exécutée, la fonction occupe un espace en mémoire et donc a une adresse. Il est possible de définir des pointeurs sur fonction. Par exemple, pour définir un pointeur f sur une fonction qui ne renvoie rien et ne prend pas de paramètres : void (*f)(); Pourquoi ne met on pas le code suivant ? void *f(); parce qu'il s'agit de la déclaration d'une fonction f renvoyant void*. Là aussi, pour utiliser un pointeur sur fonction, on doit lui affecter l'adresse d'une fonction : #include <iostream> using namespace std; void f1(int i) { cout << "f1 executee avec " << i << endl; } int main() { void (*fp)(int); fp = f1; // On définit un pointeur sur une fonction (*fp)(1); // On l'initialise // On dereference le pointeur et on appele la fonction void (*fp2)(int) = f1; // on définit un second pointeur et on l'initialise (*fp2)(2); void(*fp3)(int)=&f1; (*fp3)(3); } 6 Transtypage des pointeurs : Ici un transtypage qui ne pose pas de difficulté : // (3) Forcing a conversion from void* : void* vp = &i; // Old way produces a dangerous conversion: float* fp = (float*)vp; // The new way is equally dangerous: fp = static_cast<float*>(vp); Bien sûr un tel transtypage est dangereux.