• Aucun résultat trouvé

Gestion dynamique de la mémoire

Comme tout pointeur, une référence peut être ou non affublée d’un const. Dans un prototype de fonction, l’usage d’une référence peut ainsi se substituer à celle d’un pointeur dans un argument de fonction pour éviter que l’objet pointé ne soit recopié dans la mémoire. L’usage d’une référence rend même le code de la fonction beaucoup plus lisible car cela permet de supprimer tous les *, & et -> nécessaires lors de la manipulation de valeurs désignées par des pointeurs.

7.2

Gestion dynamique de la mémoire

7.2.1 Arithmétique des pointeurs.

Se déplacer dans la mémoire.

Comme nous l’avons écrit, un pointeur est une adresse mémoire, i.e. un numéro d’octet dans la mémoire, transcrit usuellement en base hexadécimale. Il y a tout d’abord la valeur nulle, donnée par le mot-clef NULL qui pointe vers un soi-disant 0-ème octet de la mémoire qui n’existe donc pas ! Tout accès par * à un pointeur de valeur NULL donne une erreur de segmentation.

Les pointeurs peuvent être translatés et incrémentés puisqu’ils sont stockés sous forme d’entiers. Un incrément de 1 par la commande p++ sur un pointeur de type double * augmente la valeur de p de la taille nécessaire pour stocker un double. De manière générale, une opération p=p+n où p est un pointeur de type TYPE * et n un entier augmente p de n fois la taille occupée en mémoire par une variable de type TYPE .

Considérons l’exemple suivant :

short int *p=NULL; 2 int *q=NULL;

long double *r=NULL;

4 cout << p << " " << q << " " << r << endl; //cela affiche: 0 0 0 6 p++; q++; 8 r++; cout << p << " " << q << " " << r << endl; 10 //cela affiche: 0x2 0x4 0x10

cout << q+512 ; //cela affiche: 0x804

Essayons de comprendre l’affichage. Comme vu précédemment, le préfixe 0x indique qu’il faut la valeur qui suit en hexadécimal. Un entier de type short int est codé sur deux octets : décaler p de 1 consiste donc à passer du zéro-ème octet au deuxième. Un long double est codé sur 16 octets, or 16 s’écrit 10 en base 16 : incrémenter r par r décale l’adresse de 16 octets. Après la ligne 7, q vaut 4. On ajoute alors 512 fois la taille en octets d’un int codé sur 4 octets ; q vaut alors 2048+4=2052 qui s’écrit 80416 en

base 16.

Pour connaître le nombre d’octets en mémoire occupé par une variable d’un type TYPE , il suffit d’appeler la fonction sizeof(TYPE) . C’est ainsi qu’on connaît l’effet de ++ sur un pointeur TYPE *.

Lien avec les tableaux.

L’usage de l’arithmétique des pointeurs nous permet de mieux comprendre la structure des tableaux. Un tableau double t[50] correspond dans la mémoire à 50 double stockés consécutivement. Techniquement, il suffit donc de connaître l’adresse de la première case et la taille d’une case ; la taille sert pour le créer en allouant la bonne quantité de mémoire et pour éviter ensuite les erreurs de segmentation.

Dans la déclaration double t[50]; , l’objet t est en fait un pointeur vers le premier élément, i.e. l’élément numéroté 0. Pour le vérifier, il suffit d’afficher la valeur de *t et constater que cela correspond bien à la valeur du premier élément. De la même manière, l’accès à l’élément de la case numéro k (i.e. la k+1-ème case du tableau) peut se faire par la commande *(t+k) ; c’est bien sûr moins sympathique à écrire et c’est pourquoi existe la syntaxe t[k] déjà rencontrée.

Ce mécanisme de gestion des tableaux explique également pourquoi on obtient une erreur de segmentation si l’on écrit t[i] avec un entier i qui n’est pas dans l’ensemble {0, 1, 2, . . . , 49} puisqu’une telle case n’est pas attribuée au programme dans la mémoire. 7.2.2 Allocation et désallocation de mémoire

Jusqu’à maintenant, nous avons expliqué comment les types simples, les tableaux statiques et les modèles de classe de la STL sont codés en mémoire mais nous n’avons pas géré la mémoire nous-même : cela a été fait automatiquement par le programme et le système d’exploitation lors de la déclaration de la variable. Cela a un inconvénient majeur : il a fallu attribuer un nom à chaque emplacement lors de l’écriture du programme et réserver des plages de mémoire de taille immuable pour les tableaux statiques3. En pratique, cela n’est en général pas suffisant car les besoins évoluent selon l’utilisateur et la taille des données à traiter.

Les pointeurs permettent de réserver et de libérer de la mémoire de manière très flexible avec les mots-clefs respectifs new et delete.

Soit p un pointeur de type TYPE *. La commande p=new TYPE; demande au système

d’exploitation de réserver en mémoire un emplacement de la taille nécessaire pour stocker un objet de type TYPE , récupère l’adresse de cet emplacement et la stocke dans la variable

p . La valeur stockée à cet emplacement est alors accessible uniquement par *p .

La commande delete p; ordonne au système d’exploitation de libérer l’emplacement

réservé précédemment. La variable p existe toujours bien évidemment mais ne pointe plus vers une zone accessible (la commande *p donne alors un résultat indéterminé et le plus souvent une erreur de segmentation).

Il existe également une version étendue de new et delete pour réserver des plages mémoires plus grandes. La syntaxe p=new TYPE[N]; où N est un entier positif demande

au système d’exploitation de réserver dans la mémoire une zone contigüe de mémoire pour stocker consécutivement N objets de type TYPE . L’accès au premier élément se fait là encore par *p et l’accès au 𝑘-ème par *(p+k) ou p[k] . La désallocation de la mémoire se fait alors delete [] p; .

Il faut faire attention à ne pas mélanger les versions simple et étendue. Si un emplacement étendue est réservé avec new [] , il faut impérativement le libérer avec delete [] sinon

3. Le modèle de classe std::vector , derrière lequel se cache un usage astucieux de new [] et

7.2. GESTION DYNAMIQUE DE LA MÉMOIRE 127 0 1 1 1 0 1 0 1 1 1 0 1 0 0 0 1 0 1 0 0 1 0 0 0 1 1 1 0 1 0 1 1 0 1 1 1 0 1 0 0 0 0 0 1 0 1 0 1 0 0 1 1 1 0 0 1 1 1 1 1 1 0 1 0 0 0 1 1 1 1 1 0 1 0 1 1 0 0 0 1 0 0 1 1 0 1 0 1 1 0 0 1 0 1 1 1 0 0 1 0 1 1 0 0 0 1 0 0 1 0 1 1 1 0 0 1 1 0 1 1 0 0 0 0 1 1 0 0 0 0 1 1 0 0 1 1 1 1 0 1 1 0 0 1 1 0 0 0 1 1 0 1 1 1 0 1 1 0 1 0 1 0 1 0 1 0 1 1 0 1 0 1 0 1 1 0 1 0 1 0 1 0 1 1 0 0 0 0 0 1 1 0 1 1 1 0 1 1 1 1 1 0 0 0 0 1 1 0 1 1 0 1 1 0 1 1 1 1 0 0 1 1 0 1 0 0 0 0 1 0 1 1 0 0 0 1 1 1 1 1 t

Figure 7.2 – Structure en mémoire d’un tableau dynamique déclaré par short int *t = new short int [2]; . Le pointeur t a pour valeur l’adresse 0xB (11 en hexadécimal, 1011 en base 2) de l’espace mémoire alloué à travers la commande new

(ici délimité par des pointillés). L’adresse de t est en revanche 0x2 puisque la valeur de t est stockée sur le deuxième octet. L’espace alloué occupe 4 octets, i.e. deux fois la taille d’un short int.

le comportement du programme est indéfini.

Jusqu’ici, il peut sembler que rien ne distingue en apparence l’usage de new/delete

sur des pointeurs de la déclaration d’une variable. Il existe une différence majeure néanmoins qui réside dans la possibilité de réutiliser un pointeur pour un usage différent dans un même programme alors qu’une déclaration de variable fige la nature de cette dernière.

Prenons l’exemple du programme suivant : int main() { 2 double * p; // Premiere utilisation 4 p=new double; *p=3.34; 6 std::cout << *p << std::endl; delete p; 8 //Seconde utilisation p=new double[57];

10 for( int i=0; i<57; i++) p[i]=0.5*i*i;

delete [] p;

12

return 0;

14 }

Dans ce code, une seule variable est déclarée : le pointeur p . Entre les lignes 4 et 7, il sert à stocker et afficher un seul nombre réel. La mémoire dont l’adresse est dans p est ensuite libérée et réattribuée pour stocker cette fois-ci 57 nombres réels entre les lignes 9 et 11.

Avec un seul pointeur et un usage astucieux de new et delete, nous avons pu manipuler la mémoire de manière très différente en utilisant une unique variable p tout au long du programme.

C’est cette liberté liée aux pointeurs qui est utilisée dans le modèle de classe vector (les changements de capacité se font à travers des usages de new et delete) ainsi que

dans les exemples de la section 7.4. Les constructeurs et destructeurs du chapitre5 seront également basés sur cette gestion de la mémoire par pointeur.

Valeur en cas d’échec. Les mots-clefs new et delete se révèlent également utiles lorsque d’énormes volumes de données sont en jeu. En effet, si la mémoire vive (RAM) de l’ordinateur est déjà très remplie ou tout simplement insuffisante, il se peut qu’une instruction du type T * p=new T[N]; échoue si un objet de type T occupe beaucoup de

place et que N est trop grand. Dans ce cas-là, l’espace mémoire nécessaire n’est pas réservé et, au prochain appel de type p[i] , une erreur de segmentation viendra interrompre subitement le programme. Ce n’est pas trop grave pour un programme de petite ampleur sur un ordinateur personnel ; c’est très grave pour un programme qui aurait tourné plusieurs semaines ou qui gère des activités critiques (cf. la ligne 14 du métro par exemple !). Il est alors absolument nécessaire de se prémunir de ce type d’erreur.

Pour cela, il existe la convention suivante : si l’allocation de mémoire échoue, alors new

renvoie un pointeur de valeur NULL. Après une instruction du type T * p=new T[N]; , il

suffit de tester si p !=NULL pour décider de la continuation du programme si le résultat est true) ou d’une procédure d’arrêt (sauvegarde des données importantes, solutions alternatives, etc) si le résultat est false.

Le nouveau standardC++ 2011 introduit également le mot-clef nullptr qui vise à se substituer à NULL. Les différences entre les deux sont néanmoins au-delà de la perspective de cours.

7.3

Gestion de la mémoire dans les classes

Documents relatifs